chore: track caller resolution module

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-01 17:13:35 +05:30
parent 5e3ccbd040
commit d0df6618b5
3 changed files with 255 additions and 0 deletions

View File

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

View File

@@ -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 {}

View File

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