mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
chore: track caller resolution module
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
26
src/caller/caller-resolution.controller.ts
Normal file
26
src/caller/caller-resolution.controller.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/caller/caller-resolution.module.ts
Normal file
13
src/caller/caller-resolution.module.ts
Normal 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 {}
|
||||||
216
src/caller/caller-resolution.service.ts
Normal file
216
src/caller/caller-resolution.service.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user