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 { 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( `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 } }`, { id: leadId, data: { patientId } }, auth, ); } }