mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
Audited all 23 sidecar create-mutation call sites; 7 were missing the top-level data.name field that the platform uses as record title: - caller-resolution.service.ts createPatient — full name from first/last - maint.controller.ts createPatient (backfill-lead-patient-links) — same - widget.service.ts createPatient (chat path + booking path) — full name - widget.service.ts createAppointment — "<Patient> — <date>" - worklist/missed-queue.service.ts createCall — "Missed — <phone>" - rules-engine/actions/escalate.action.ts createPerformanceAlert — "<agent>: <message> (<value>)" - supervisor/agent-history.service.ts createAgentEvent / createAgentSession Cosmetic only — the app fetches fullName/agentName for display, so end users never saw "Untitled". Fixes platform-side admin browsing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
218 lines
9.3 KiB
TypeScript
218 lines
9.3 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
|
|
|
export type ResolvedCaller = {
|
|
leadId: string;
|
|
patientId: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
phone: string;
|
|
isNew: boolean; // true if no Lead/Patient exists for this phone
|
|
};
|
|
|
|
@Injectable()
|
|
export class CallerResolutionService {
|
|
private readonly logger = new Logger(CallerResolutionService.name);
|
|
|
|
constructor(
|
|
private readonly platform: PlatformGraphqlService,
|
|
) {}
|
|
|
|
// 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}`);
|
|
}
|
|
|
|
// Lookup lead + patient by phone, in parallel.
|
|
const [lead, patient] = await Promise.all([
|
|
this.findLeadByPhone(normalized, auth),
|
|
this.findPatientByPhone(normalized, auth),
|
|
]);
|
|
|
|
let result: ResolvedCaller;
|
|
|
|
if (lead && patient) {
|
|
// Both exist — link them if not already linked
|
|
if (!lead.patientId) {
|
|
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,
|
|
firstName: lead.firstName || patient.firstName,
|
|
lastName: lead.lastName || patient.lastName,
|
|
phone: normalized,
|
|
isNew: false,
|
|
};
|
|
} else if (lead && !patient) {
|
|
// Lead exists, no patient — create patient
|
|
const newPatient = await this.createPatient(lead.firstName, lead.lastName, normalized, auth);
|
|
await this.linkLeadToPatient(lead.id, newPatient.id, auth);
|
|
this.logger.log(`[RESOLVE] Created patient ${newPatient.id} for existing lead ${lead.id}`);
|
|
result = {
|
|
leadId: lead.id,
|
|
patientId: newPatient.id,
|
|
firstName: lead.firstName,
|
|
lastName: lead.lastName,
|
|
phone: normalized,
|
|
isNew: false,
|
|
};
|
|
} else if (!lead && patient) {
|
|
// 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,
|
|
firstName: patient.firstName,
|
|
lastName: patient.lastName,
|
|
phone: normalized,
|
|
isNew: false,
|
|
};
|
|
} else {
|
|
// 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: '',
|
|
firstName: '',
|
|
lastName: '',
|
|
phone: normalized,
|
|
isNew: true,
|
|
};
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// 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: 1, filter: { contactPhone: { primaryPhoneNumber: { like: "%${phone10}" } } }) { edges { node {
|
|
id
|
|
contactName { firstName lastName }
|
|
patientId
|
|
} } } }`,
|
|
undefined,
|
|
auth,
|
|
);
|
|
const match = data.leads.edges[0]?.node;
|
|
if (!match) return null;
|
|
return {
|
|
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}`);
|
|
return 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: 1, filter: { phones: { primaryPhoneNumber: { like: "%${phone10}" } } }) { edges { node {
|
|
id
|
|
fullName { firstName lastName }
|
|
patientType
|
|
} } } }`,
|
|
undefined,
|
|
auth,
|
|
);
|
|
const match = data.patients.edges[0]?.node;
|
|
if (!match) return null;
|
|
return {
|
|
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}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async createPatient(firstName: string, lastName: string, phone: string, auth: string): Promise<{ id: string }> {
|
|
const data = await this.platform.queryWithAuth<{ createPatient: { id: string } }>(
|
|
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
|
{
|
|
data: {
|
|
name: `${firstName || 'Unknown'} ${lastName || ''}`.trim(),
|
|
fullName: { firstName: firstName || 'Unknown', lastName: lastName || '' },
|
|
phones: { primaryPhoneNumber: `+91${phone}` },
|
|
patientType: 'NEW',
|
|
},
|
|
},
|
|
auth,
|
|
);
|
|
return data.createPatient;
|
|
}
|
|
|
|
private async createLead(firstName: string, lastName: string, phone: string, patientId: string, auth: string): Promise<{ id: string }> {
|
|
const data = await this.platform.queryWithAuth<{ createLead: { id: string } }>(
|
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
|
{
|
|
data: {
|
|
name: `${firstName} ${lastName}`.trim() || 'Unknown Caller',
|
|
contactName: { firstName: firstName || 'Unknown', lastName: lastName || '' },
|
|
contactPhone: { primaryPhoneNumber: `+91${phone}` },
|
|
source: 'PHONE',
|
|
status: 'NEW',
|
|
patientId,
|
|
},
|
|
},
|
|
auth,
|
|
);
|
|
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 } }`,
|
|
{ id: leadId, data: { patientId } },
|
|
auth,
|
|
);
|
|
}
|
|
}
|