diff --git a/src/caller/caller-resolution.controller.ts b/src/caller/caller-resolution.controller.ts new file mode 100644 index 0000000..112f8cd --- /dev/null +++ b/src/caller/caller-resolution.controller.ts @@ -0,0 +1,26 @@ +import { Controller, Post, Body, Headers, HttpException, HttpStatus, Logger } from '@nestjs/common'; +import { CallerResolutionService } from './caller-resolution.service'; + +@Controller('api/caller') +export class CallerResolutionController { + private readonly logger = new Logger(CallerResolutionController.name); + + constructor(private readonly resolution: CallerResolutionService) {} + + @Post('resolve') + async resolve( + @Body('phone') phone: string, + @Headers('authorization') auth: string, + ) { + if (!phone) { + throw new HttpException('phone is required', HttpStatus.BAD_REQUEST); + } + if (!auth) { + throw new HttpException('Authorization header required', HttpStatus.UNAUTHORIZED); + } + + this.logger.log(`[RESOLVE] Resolving caller: ${phone}`); + const result = await this.resolution.resolve(phone, auth); + return result; + } +} diff --git a/src/caller/caller-resolution.module.ts b/src/caller/caller-resolution.module.ts new file mode 100644 index 0000000..c167d64 --- /dev/null +++ b/src/caller/caller-resolution.module.ts @@ -0,0 +1,13 @@ +import { Module } 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], + controllers: [CallerResolutionController], + providers: [CallerResolutionService], + exports: [CallerResolutionService], +}) +export class CallerResolutionModule {} diff --git a/src/caller/caller-resolution.service.ts b/src/caller/caller-resolution.service.ts new file mode 100644 index 0000000..719786b --- /dev/null +++ b/src/caller/caller-resolution.service.ts @@ -0,0 +1,216 @@ +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; + patientId: string; + firstName: string; + lastName: string; + phone: string; + isNew: boolean; // true if we just created the lead+patient pair +}; + +@Injectable() +export class CallerResolutionService { + private readonly logger = new Logger(CallerResolutionService.name); + + constructor( + private readonly platform: PlatformGraphqlService, + private readonly cache: SessionService, + ) {} + + // Resolve a caller by phone number. Always returns a paired lead + patient. + 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); + + 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}`); + } + 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}`); + result = { + leadId: newLead.id, + patientId: patient.id, + firstName: patient.firstName, + lastName: patient.lastName, + phone: normalized, + isNew: false, + }; + } else { + // Neither exists — create both + const newPatient = await this.createPatient('', '', normalized, auth); + const newLead = await this.createLead('', '', normalized, newPatient.id, auth); + this.logger.log(`[RESOLVE] Created new lead ${newLead.id} + patient ${newPatient.id} for ${normalized}`); + result = { + leadId: newLead.id, + patientId: newPatient.id, + firstName: '', + lastName: '', + phone: normalized, + isNew: true, + }; + } + + // 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 + } + + 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 { + 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; + }); + + if (!match) return null; + + return { + id: match.node.id, + firstName: match.node.contactName?.firstName ?? '', + lastName: match.node.contactName?.lastName ?? '', + patientId: match.node.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 } | null> { + try { + const data = await this.platform.queryWithAuth<{ patients: { edges: { node: any }[] } }>( + `{ patients(first: 200) { edges { node { + id + fullName { firstName lastName } + phones { primaryPhoneNumber } + } } } }`, + 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; + }); + + if (!match) return null; + + return { + id: match.node.id, + firstName: match.node.fullName?.firstName ?? '', + lastName: match.node.fullName?.lastName ?? '', + }; + } 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: { + 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 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, + ); + } +}