mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
fix+feat: morning QA fixes, worklist pagination, misc sidecar improvements
- caller-resolution: drop cache, use indexed phone filter (lead.contactPhone.primaryPhoneNumber.like) - worklist: externalize page size (WORKLIST_PAGE_SIZE × WORKLIST_MAX_PAGES), paginate getMissedCalls/getAssignedLeads/getPendingFollowUps - maint: unlock-agent, force-ready, backfill-caller-resolution, clear-analysis-cache, fix-timestamps - ozonetel agent.service: force logout+re-login on "already logged in" - ai chat: context expansion - livekit-agent: updates - widget: session handling - masterdata: clinic list cache Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import { generateText, streamText, tool, stepCountIs } from 'ai';
|
|||||||
import type { LanguageModel } from 'ai';
|
import type { LanguageModel } from 'ai';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||||
import { createAiModel, isAiConfigured } from './ai-provider';
|
import { createAiModel, isAiConfigured } from './ai-provider';
|
||||||
import { AiConfigService } from '../config/ai-config.service';
|
import { AiConfigService } from '../config/ai-config.service';
|
||||||
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../shared/doctor-utils';
|
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../shared/doctor-utils';
|
||||||
@@ -26,6 +27,7 @@ export class AiChatController {
|
|||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
private platform: PlatformGraphqlService,
|
private platform: PlatformGraphqlService,
|
||||||
private aiConfig: AiConfigService,
|
private aiConfig: AiConfigService,
|
||||||
|
private caller: CallerResolutionService,
|
||||||
) {
|
) {
|
||||||
const cfg = aiConfig.getConfig();
|
const cfg = aiConfig.getConfig();
|
||||||
this.aiModel = createAiModel({
|
this.aiModel = createAiModel({
|
||||||
@@ -431,28 +433,75 @@ export class AiChatController {
|
|||||||
this.logger.log(`[TOOL] create_lead: ${name} | ${phoneNumber} | ${interest}`);
|
this.logger.log(`[TOOL] create_lead: ${name} | ${phoneNumber} | ${interest}`);
|
||||||
try {
|
try {
|
||||||
const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10);
|
const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10);
|
||||||
const result = await platformService.queryWithAuth<any>(
|
const resolved = await this.caller.resolve(cleanPhone, auth);
|
||||||
|
const firstName = name.split(' ')[0];
|
||||||
|
const lastName = name.split(' ').slice(1).join(' ') || '';
|
||||||
|
|
||||||
|
if (resolved.isNew) {
|
||||||
|
// Net-new caller — create Patient + Lead with
|
||||||
|
// the AI-collected name from the conversation.
|
||||||
|
let patientId: string | undefined;
|
||||||
|
try {
|
||||||
|
const p = await platformService.queryWithAuth<any>(
|
||||||
|
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
fullName: { firstName, lastName },
|
||||||
|
phones: { primaryPhoneNumber: `+91${cleanPhone}` },
|
||||||
|
patientType: 'NEW',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auth,
|
||||||
|
);
|
||||||
|
patientId = p?.createPatient?.id;
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`[TOOL] create_lead patient create failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
const created = await platformService.queryWithAuth<any>(
|
||||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||||
{
|
{
|
||||||
data: {
|
data: {
|
||||||
name: `AI Enquiry — ${name}`,
|
name: `AI Enquiry — ${name}`,
|
||||||
contactName: {
|
contactName: { firstName, lastName },
|
||||||
firstName: name.split(' ')[0],
|
|
||||||
lastName: name.split(' ').slice(1).join(' ') || '',
|
|
||||||
},
|
|
||||||
contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` },
|
contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` },
|
||||||
source: 'PHONE',
|
source: 'PHONE',
|
||||||
status: 'NEW',
|
status: 'NEW',
|
||||||
interestedService: interest,
|
interestedService: interest,
|
||||||
|
...(patientId ? { patientId } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auth,
|
||||||
|
);
|
||||||
|
const id = created?.createLead?.id;
|
||||||
|
if (id) {
|
||||||
|
return { created: true, leadId: id, message: `Lead created for ${name}. Our team will follow up on ${phoneNumber}.` };
|
||||||
|
}
|
||||||
|
return { created: false, message: 'Lead creation failed.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing record — update with AI-collected name.
|
||||||
|
await platformService.queryWithAuth<any>(
|
||||||
|
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
id: resolved.leadId,
|
||||||
|
data: {
|
||||||
|
name: `AI Enquiry — ${name}`,
|
||||||
|
contactName: { firstName, lastName },
|
||||||
|
source: 'PHONE',
|
||||||
|
status: 'NEW',
|
||||||
|
interestedService: interest,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
auth,
|
auth,
|
||||||
);
|
);
|
||||||
const id = result?.createLead?.id;
|
if (resolved.patientId) {
|
||||||
if (id) {
|
await platformService.queryWithAuth<any>(
|
||||||
return { created: true, leadId: id, message: `Lead created for ${name}. Our team will follow up on ${phoneNumber}.` };
|
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: resolved.patientId, data: { fullName: { firstName, lastName } } },
|
||||||
|
auth,
|
||||||
|
).catch(() => {});
|
||||||
}
|
}
|
||||||
return { created: false, message: 'Lead creation failed.' };
|
return { created: true, leadId: resolved.leadId, message: `Lead updated for ${name}. Our team will follow up on ${phoneNumber}.` };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.error(`[TOOL] create_lead failed: ${err.message}`);
|
this.logger.error(`[TOOL] create_lead failed: ${err.message}`);
|
||||||
return { created: false, message: `Failed: ${err.message}` };
|
return { created: false, message: `Failed: ${err.message}` };
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { PlatformModule } from '../platform/platform.module';
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
import { AiEnrichmentService } from './ai-enrichment.service';
|
import { AiEnrichmentService } from './ai-enrichment.service';
|
||||||
import { AiChatController } from './ai-chat.controller';
|
import { AiChatController } from './ai-chat.controller';
|
||||||
|
import { CallerResolutionModule } from '../caller/caller-resolution.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule],
|
imports: [PlatformModule, forwardRef(() => CallerResolutionModule)],
|
||||||
controllers: [AiChatController],
|
controllers: [AiChatController],
|
||||||
providers: [AiEnrichmentService],
|
providers: [AiEnrichmentService],
|
||||||
exports: [AiEnrichmentService],
|
exports: [AiEnrichmentService],
|
||||||
|
|||||||
@@ -99,17 +99,9 @@ export class LeadEnrichController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Invalidate the caller cache so the next incoming call from
|
// Caller resolution no longer caches — every resolve() hits the
|
||||||
// this phone number does a fresh platform lookup (and picks
|
// platform fresh via an indexed phone filter. No invalidation
|
||||||
// up the corrected identity + new summary).
|
// needed after enrichment.
|
||||||
if (body?.phone) {
|
|
||||||
try {
|
|
||||||
await this.callerResolution.invalidate(body.phone);
|
|
||||||
this.logger.log(`[LEAD-ENRICH] Caller cache invalidated for ${body.phone}`);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(`[LEAD-ENRICH] Failed to invalidate caller cache: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`[LEAD-ENRICH] Lead ${leadId} enriched successfully`);
|
this.logger.log(`[LEAD-ENRICH] Lead ${leadId} enriched successfully`);
|
||||||
|
|
||||||
|
|||||||
@@ -23,14 +23,4 @@ export class CallerResolutionController {
|
|||||||
const result = await this.resolution.resolve(phone, auth);
|
const result = await this.resolution.resolve(phone, auth);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('invalidate')
|
|
||||||
async invalidate(@Body('phone') phone: string) {
|
|
||||||
if (!phone) {
|
|
||||||
throw new HttpException('phone is required', HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
this.logger.log(`[RESOLVE] Invalidating cache for: ${phone}`);
|
|
||||||
await this.resolution.invalidate(phone);
|
|
||||||
return { status: 'ok' };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { PlatformModule } from '../platform/platform.module';
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { CallerResolutionController } from './caller-resolution.controller';
|
import { CallerResolutionController } from './caller-resolution.controller';
|
||||||
import { CallerResolutionService } from './caller-resolution.service';
|
import { CallerResolutionService } from './caller-resolution.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule, AuthModule],
|
imports: [PlatformModule, forwardRef(() => AuthModule)],
|
||||||
controllers: [CallerResolutionController],
|
controllers: [CallerResolutionController],
|
||||||
providers: [CallerResolutionService],
|
providers: [CallerResolutionService],
|
||||||
exports: [CallerResolutionService],
|
exports: [CallerResolutionService],
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
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 = {
|
export type ResolvedCaller = {
|
||||||
leadId: string;
|
leadId: string;
|
||||||
@@ -11,7 +7,7 @@ export type ResolvedCaller = {
|
|||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
isNew: boolean; // true if we just created the lead+patient pair
|
isNew: boolean; // true if no Lead/Patient exists for this phone
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -20,33 +16,24 @@ export class CallerResolutionService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly platform: PlatformGraphqlService,
|
private readonly platform: PlatformGraphqlService,
|
||||||
private readonly cache: SessionService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// Resolve a caller by phone number.
|
// Resolve a caller by phone number via indexed platform queries. No
|
||||||
// Looks up existing lead + patient by phone. If neither exists, returns
|
// cache — every call hits the DB fresh. Cache was previously used to
|
||||||
// isNew: true with no IDs — records are NOT created automatically.
|
// compensate for client-side `leads(first: 200)` scans, but we now
|
||||||
// Record creation happens when the agent explicitly books an appointment
|
// filter by phone directly which is O(log n) with the DB index.
|
||||||
// or logs an enquiry (per PRD: "System will not identify the patient —
|
// Cost: ~2 fast queries per resolve; eventual-consistency window = 0.
|
||||||
// no summary shown" for unregistered numbers).
|
|
||||||
async resolve(phone: string, auth: string): Promise<ResolvedCaller> {
|
async resolve(phone: string, auth: string): Promise<ResolvedCaller> {
|
||||||
const normalized = phone.replace(/\D/g, '').slice(-10);
|
const normalized = phone.replace(/\D/g, '').slice(-10);
|
||||||
if (normalized.length < 10) {
|
if (normalized.length < 10) {
|
||||||
throw new Error(`Invalid phone number: ${phone}`);
|
throw new Error(`Invalid phone number: ${phone}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Check cache
|
// Lookup lead + patient by phone, in parallel.
|
||||||
const cached = await this.cache.getCache(`${CACHE_PREFIX}${normalized}`);
|
const [lead, patient] = await Promise.all([
|
||||||
if (cached) {
|
this.findLeadByPhone(normalized, auth),
|
||||||
this.logger.log(`[RESOLVE] Cache hit for ${normalized}`);
|
this.findPatientByPhone(normalized, auth),
|
||||||
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;
|
let result: ResolvedCaller;
|
||||||
|
|
||||||
@@ -56,6 +43,11 @@ export class CallerResolutionService {
|
|||||||
await this.linkLeadToPatient(lead.id, patient.id, auth);
|
await this.linkLeadToPatient(lead.id, patient.id, auth);
|
||||||
this.logger.log(`[RESOLVE] Linked existing lead ${lead.id} → patient ${patient.id}`);
|
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 = {
|
result = {
|
||||||
leadId: lead.id,
|
leadId: lead.id,
|
||||||
patientId: patient.id,
|
patientId: patient.id,
|
||||||
@@ -81,6 +73,9 @@ export class CallerResolutionService {
|
|||||||
// Patient exists, no lead — create lead
|
// Patient exists, no lead — create lead
|
||||||
const newLead = await this.createLead(patient.firstName, patient.lastName, normalized, patient.id, auth);
|
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}`);
|
this.logger.log(`[RESOLVE] Created lead ${newLead.id} for existing patient ${patient.id}`);
|
||||||
|
if (patient.patientType === 'NEW') {
|
||||||
|
this.upgradeToReturning(patient.id, auth);
|
||||||
|
}
|
||||||
result = {
|
result = {
|
||||||
leadId: newLead.id,
|
leadId: newLead.id,
|
||||||
patientId: patient.id,
|
patientId: patient.id,
|
||||||
@@ -90,9 +85,15 @@ export class CallerResolutionService {
|
|||||||
isNew: false,
|
isNew: false,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// Neither exists — return empty result, don't create records.
|
// Neither exists — return empty IDs with isNew=true. Caller
|
||||||
// Agent will create records when they book an appointment or log an enquiry.
|
// code is responsible for creating records with the real name
|
||||||
this.logger.log(`[RESOLVE] No existing records for ${normalized} — returning unresolved`);
|
// 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 = {
|
result = {
|
||||||
leadId: '',
|
leadId: '',
|
||||||
patientId: '',
|
patientId: '',
|
||||||
@@ -103,43 +104,30 @@ export class CallerResolutionService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Cache the result
|
|
||||||
await this.cache.setCache(`${CACHE_PREFIX}${normalized}`, JSON.stringify(result), CACHE_TTL);
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate cache for a phone number (call after updates)
|
// Indexed lookup — platform filters by phone server-side. Matches on
|
||||||
async invalidate(phone: string): Promise<void> {
|
// the last 10 digits regardless of stored format (+919XXXX / 91XXXX /
|
||||||
const normalized = phone.replace(/\D/g, '').slice(-10);
|
// XXXX / +91-XXXX), via the `like: "%XXXXXXXXXX"` predicate.
|
||||||
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> {
|
private async findLeadByPhone(phone10: string, auth: string): Promise<{ id: string; firstName: string; lastName: string; patientId: string | null } | null> {
|
||||||
try {
|
try {
|
||||||
const data = await this.platform.queryWithAuth<{ leads: { edges: { node: any }[] } }>(
|
const data = await this.platform.queryWithAuth<{ leads: { edges: { node: any }[] } }>(
|
||||||
`{ leads(first: 200) { edges { node {
|
`{ leads(first: 1, filter: { contactPhone: { primaryPhoneNumber: { like: "%${phone10}" } } }) { edges { node {
|
||||||
id
|
id
|
||||||
contactName { firstName lastName }
|
contactName { firstName lastName }
|
||||||
contactPhone { primaryPhoneNumber }
|
|
||||||
patientId
|
patientId
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined,
|
undefined,
|
||||||
auth,
|
auth,
|
||||||
);
|
);
|
||||||
|
const match = data.leads.edges[0]?.node;
|
||||||
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;
|
if (!match) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: match.node.id,
|
id: match.id,
|
||||||
firstName: match.node.contactName?.firstName ?? '',
|
firstName: match.contactName?.firstName ?? '',
|
||||||
lastName: match.node.contactName?.lastName ?? '',
|
lastName: match.contactName?.lastName ?? '',
|
||||||
patientId: match.node.patientId || null,
|
patientId: match.patientId || null,
|
||||||
};
|
};
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.warn(`[RESOLVE] Lead lookup failed: ${err.message}`);
|
this.logger.warn(`[RESOLVE] Lead lookup failed: ${err.message}`);
|
||||||
@@ -147,29 +135,24 @@ export class CallerResolutionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findPatientByPhone(phone10: string, auth: string): Promise<{ id: string; firstName: string; lastName: string } | null> {
|
private async findPatientByPhone(phone10: string, auth: string): Promise<{ id: string; firstName: string; lastName: string; patientType: string | null } | null> {
|
||||||
try {
|
try {
|
||||||
const data = await this.platform.queryWithAuth<{ patients: { edges: { node: any }[] } }>(
|
const data = await this.platform.queryWithAuth<{ patients: { edges: { node: any }[] } }>(
|
||||||
`{ patients(first: 200) { edges { node {
|
`{ patients(first: 1, filter: { phones: { primaryPhoneNumber: { like: "%${phone10}" } } }) { edges { node {
|
||||||
id
|
id
|
||||||
fullName { firstName lastName }
|
fullName { firstName lastName }
|
||||||
phones { primaryPhoneNumber }
|
patientType
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined,
|
undefined,
|
||||||
auth,
|
auth,
|
||||||
);
|
);
|
||||||
|
const match = data.patients.edges[0]?.node;
|
||||||
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;
|
if (!match) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: match.node.id,
|
id: match.id,
|
||||||
firstName: match.node.fullName?.firstName ?? '',
|
firstName: match.fullName?.firstName ?? '',
|
||||||
lastName: match.node.fullName?.lastName ?? '',
|
lastName: match.fullName?.lastName ?? '',
|
||||||
|
patientType: match.patientType ?? null,
|
||||||
};
|
};
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.warn(`[RESOLVE] Patient lookup failed: ${err.message}`);
|
this.logger.warn(`[RESOLVE] Patient lookup failed: ${err.message}`);
|
||||||
@@ -210,6 +193,19 @@ export class CallerResolutionService {
|
|||||||
return data.createLead;
|
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> {
|
private async linkLeadToPatient(leadId: string, patientId: string, auth: string): Promise<void> {
|
||||||
await this.platform.queryWithAuth<any>(
|
await this.platform.queryWithAuth<any>(
|
||||||
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
||||||
|
|||||||
@@ -22,6 +22,16 @@ export default () => ({
|
|||||||
missedQueue: {
|
missedQueue: {
|
||||||
pollIntervalMs: parseInt(process.env.MISSED_QUEUE_POLL_INTERVAL_MS ?? '30000', 10),
|
pollIntervalMs: parseInt(process.env.MISSED_QUEUE_POLL_INTERVAL_MS ?? '30000', 10),
|
||||||
},
|
},
|
||||||
|
worklist: {
|
||||||
|
// Per-page fetch size from the platform GraphQL endpoint. Tuned to
|
||||||
|
// balance response size vs. page count. Platform's Relay pagination
|
||||||
|
// typically caps at 100–200 per page.
|
||||||
|
pageSize: parseInt(process.env.WORKLIST_PAGE_SIZE ?? '50', 10),
|
||||||
|
// Hard ceiling on pages fetched per poll. Safety valve against
|
||||||
|
// unbounded cost when a tenant has thousands of pending callbacks.
|
||||||
|
// maxPages * pageSize = effective worklist size.
|
||||||
|
maxPages: parseInt(process.env.WORKLIST_MAX_PAGES ?? '10', 10),
|
||||||
|
},
|
||||||
ai: {
|
ai: {
|
||||||
provider: process.env.AI_PROVIDER ?? 'openai',
|
provider: process.env.AI_PROVIDER ?? 'openai',
|
||||||
anthropicApiKey: process.env.ANTHROPIC_API_KEY ?? '',
|
anthropicApiKey: process.env.ANTHROPIC_API_KEY ?? '',
|
||||||
|
|||||||
@@ -27,6 +27,27 @@ async function gql<T = any>(query: string, variables?: Record<string, unknown>):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve a phone to a {leadId, patientId} pair via the sidecar's
|
||||||
|
// caller-resolution endpoint. Always returns populated IDs (creates
|
||||||
|
// placeholder lead+patient when none exist).
|
||||||
|
async function resolveCaller(phone: string): Promise<{ leadId: string; patientId: string; firstName: string; lastName: string; isNew: boolean } | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${SIDECAR_URL}/api/caller/resolve`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ phone }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error('[AGENT-RESOLVE] Failed:', res.status, await res.text().catch(() => ''));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return await res.json();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[AGENT-RESOLVE] Failed:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Hospital context — loaded on startup
|
// Hospital context — loaded on startup
|
||||||
let hospitalContext = {
|
let hospitalContext = {
|
||||||
doctors: [] as Array<{ name: string; department: string; specialty: string; id: string }>,
|
doctors: [] as Array<{ name: string; department: string; specialty: string; id: string }>,
|
||||||
@@ -133,24 +154,53 @@ const bookAppointment = llm.tool({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create or find lead
|
// Resolve caller — if isNew, create Lead + Patient with the
|
||||||
|
// AI-collected name; otherwise update the existing record.
|
||||||
const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10);
|
const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10);
|
||||||
|
const resolved = await resolveCaller(cleanPhone);
|
||||||
|
const fn = patientName.split(' ')[0];
|
||||||
|
const ln = patientName.split(' ').slice(1).join(' ') || '';
|
||||||
|
if (resolved?.isNew) {
|
||||||
|
const p = await gql(
|
||||||
|
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||||
|
{ data: { fullName: { firstName: fn, lastName: ln }, phones: { primaryPhoneNumber: `+91${cleanPhone}` }, patientType: 'NEW' } },
|
||||||
|
);
|
||||||
|
const newPatientId = p?.createPatient?.id;
|
||||||
await gql(
|
await gql(
|
||||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||||
{
|
{
|
||||||
data: {
|
data: {
|
||||||
name: `AI — ${patientName}`,
|
name: `AI — ${patientName}`,
|
||||||
contactName: {
|
contactName: { firstName: fn, lastName: ln },
|
||||||
firstName: patientName.split(' ')[0],
|
|
||||||
lastName: patientName.split(' ').slice(1).join(' ') || '',
|
|
||||||
},
|
|
||||||
contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` },
|
contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` },
|
||||||
source: 'PHONE',
|
source: 'PHONE',
|
||||||
status: 'APPOINTMENT_SET',
|
status: 'APPOINTMENT_SET',
|
||||||
interestedService: department,
|
interestedService: department,
|
||||||
|
...(newPatientId ? { patientId: newPatientId } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else if (resolved?.leadId) {
|
||||||
|
await gql(
|
||||||
|
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
id: resolved.leadId,
|
||||||
|
data: {
|
||||||
|
name: `AI — ${patientName}`,
|
||||||
|
contactName: { firstName: fn, lastName: ln },
|
||||||
|
source: 'PHONE',
|
||||||
|
status: 'APPOINTMENT_SET',
|
||||||
|
interestedService: department,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
if (resolved.patientId) {
|
||||||
|
await gql(
|
||||||
|
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: resolved.patientId, data: { fullName: { firstName: fn, lastName: ln } } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const refNum = `GH-${Date.now().toString().slice(-6)}`;
|
const refNum = `GH-${Date.now().toString().slice(-6)}`;
|
||||||
if (result?.createAppointment?.id) {
|
if (result?.createAppointment?.id) {
|
||||||
@@ -172,25 +222,53 @@ const collectLeadInfo = llm.tool({
|
|||||||
console.log(`[LIVEKIT-AGENT] Lead: ${name} | ${phoneNumber} | ${interest}`);
|
console.log(`[LIVEKIT-AGENT] Lead: ${name} | ${phoneNumber} | ${interest}`);
|
||||||
|
|
||||||
const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10);
|
const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10);
|
||||||
const result = await gql(
|
const resolved = await resolveCaller(cleanPhone);
|
||||||
|
const fn = name.split(' ')[0];
|
||||||
|
const ln = name.split(' ').slice(1).join(' ') || '';
|
||||||
|
|
||||||
|
if (resolved?.isNew) {
|
||||||
|
// Net-new caller — create Patient + Lead with the AI-collected name.
|
||||||
|
const p = await gql(
|
||||||
|
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||||
|
{ data: { fullName: { firstName: fn, lastName: ln }, phones: { primaryPhoneNumber: `+91${cleanPhone}` }, patientType: 'NEW' } },
|
||||||
|
);
|
||||||
|
const newPatientId = p?.createPatient?.id;
|
||||||
|
const created = await gql(
|
||||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||||
{
|
{
|
||||||
data: {
|
data: {
|
||||||
name: `AI Enquiry — ${name}`,
|
name: `AI Enquiry — ${name}`,
|
||||||
contactName: {
|
contactName: { firstName: fn, lastName: ln },
|
||||||
firstName: name.split(' ')[0],
|
|
||||||
lastName: name.split(' ').slice(1).join(' ') || '',
|
|
||||||
},
|
|
||||||
contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` },
|
contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` },
|
||||||
source: 'PHONE',
|
source: 'PHONE',
|
||||||
status: 'NEW',
|
status: 'NEW',
|
||||||
interestedService: interest,
|
interestedService: interest,
|
||||||
|
...(newPatientId ? { patientId: newPatientId } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
console.log(`[LIVEKIT-AGENT] Lead created: ${created?.createLead?.id ?? 'none'} (patient ${newPatientId ?? 'none'})`);
|
||||||
|
} else if (resolved?.leadId) {
|
||||||
|
await gql(
|
||||||
|
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
id: resolved.leadId,
|
||||||
|
data: {
|
||||||
|
name: `AI Enquiry — ${name}`,
|
||||||
|
contactName: { firstName: fn, lastName: ln },
|
||||||
|
source: 'PHONE',
|
||||||
|
status: 'NEW',
|
||||||
|
interestedService: interest,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
if (resolved.patientId) {
|
||||||
if (result?.createLead?.id) {
|
await gql(
|
||||||
console.log(`[LIVEKIT-AGENT] Lead created: ${result.createLead.id}`);
|
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: resolved.patientId, data: { fullName: { firstName: fn, lastName: ln } } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(`[LIVEKIT-AGENT] Lead updated: ${resolved.leadId} (patient ${resolved.patientId})`);
|
||||||
}
|
}
|
||||||
return `Thank you ${name}. I have noted your enquiry about ${interest}. One of our team members will call you back on ${phoneNumber} shortly.`;
|
return `Thank you ${name}. I have noted your enquiry about ${interest}. One of our team members will call you back on ${phoneNumber} shortly.`;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -317,4 +317,108 @@ export class MaintController {
|
|||||||
this.logger.log(`[MAINT] Backfill complete: ${linked} linked, ${created} patients created, ${apptLinked} appointments linked, ${skipped} skipped`);
|
this.logger.log(`[MAINT] Backfill complete: ${linked} linked, ${created} patients created, ${apptLinked} appointments linked, ${skipped} skipped`);
|
||||||
return { status: 'ok', leads: { total: leads.length, linked, created, skipped }, appointments: { total: appointments.length, linked: apptLinked } };
|
return { status: 'ok', leads: { total: leads.length, linked, created, skipped }, appointments: { total: appointments.length, linked: apptLinked } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backfill Call records that lost their identity at ingest (missed-call
|
||||||
|
// webhook / poller / dispose flow before the caller-resolution wiring).
|
||||||
|
// Routes each phone through CallerResolutionService so the same code
|
||||||
|
// path the live system uses also fixes historical rows. Idempotent —
|
||||||
|
// safe to re-run; only patches calls that are currently missing
|
||||||
|
// leadName / patientId / leadId.
|
||||||
|
@Post('backfill-caller-resolution')
|
||||||
|
async backfillCallerResolution() {
|
||||||
|
this.logger.log('[MAINT] Backfill caller resolution — patching Calls + Leads via resolver');
|
||||||
|
|
||||||
|
const apiKey = process.env.PLATFORM_API_KEY ?? '';
|
||||||
|
const auth = apiKey ? `Bearer ${apiKey}` : '';
|
||||||
|
if (!auth) throw new HttpException('PLATFORM_API_KEY not configured', 500);
|
||||||
|
|
||||||
|
let callsScanned = 0;
|
||||||
|
let callsPatched = 0;
|
||||||
|
let callsSkipped = 0;
|
||||||
|
let leadsResolved = 0;
|
||||||
|
let resolveErrors = 0;
|
||||||
|
|
||||||
|
// Phone → resolved cache so multiple calls from the same number
|
||||||
|
// only resolve once during this run.
|
||||||
|
const resolvedByPhone = new Map<string, { leadId: string; patientId: string; firstName: string; lastName: string }>();
|
||||||
|
|
||||||
|
// Page through all calls in chunks of 200. We're after rows where
|
||||||
|
// leadName is empty OR leadId is null OR patientId is missing.
|
||||||
|
let cursor: string | null = null;
|
||||||
|
let hasNext = true;
|
||||||
|
while (hasNext) {
|
||||||
|
const pageQuery = cursor
|
||||||
|
? `{ calls(first: 200, after: "${cursor}") { edges { cursor node { id leadId leadName callerNumber { primaryPhoneNumber } } } pageInfo { hasNextPage endCursor } } }`
|
||||||
|
: `{ calls(first: 200) { edges { cursor node { id leadId leadName callerNumber { primaryPhoneNumber } } } pageInfo { hasNextPage endCursor } } }`;
|
||||||
|
let page: any;
|
||||||
|
try {
|
||||||
|
page = await this.platform.query<any>(pageQuery);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[MAINT] calls page query failed: ${err}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const edges = page?.calls?.edges ?? [];
|
||||||
|
hasNext = page?.calls?.pageInfo?.hasNextPage ?? false;
|
||||||
|
cursor = page?.calls?.pageInfo?.endCursor ?? null;
|
||||||
|
|
||||||
|
for (const edge of edges) {
|
||||||
|
const call = edge.node;
|
||||||
|
callsScanned++;
|
||||||
|
|
||||||
|
const phoneRaw = call.callerNumber?.primaryPhoneNumber ?? '';
|
||||||
|
const phone10 = phoneRaw.replace(/\D/g, '').slice(-10);
|
||||||
|
const needsName = !call.leadName || call.leadName === '';
|
||||||
|
const needsLead = !call.leadId;
|
||||||
|
|
||||||
|
if (!phone10 || phone10.length < 10) { callsSkipped++; continue; }
|
||||||
|
if (!needsName && !needsLead) { callsSkipped++; continue; }
|
||||||
|
|
||||||
|
let resolved = resolvedByPhone.get(phone10) ?? null;
|
||||||
|
if (!resolved) {
|
||||||
|
try {
|
||||||
|
const r = await this.callerResolution.resolve(phone10, auth);
|
||||||
|
resolved = {
|
||||||
|
leadId: r.leadId,
|
||||||
|
patientId: r.patientId,
|
||||||
|
firstName: r.firstName,
|
||||||
|
lastName: r.lastName,
|
||||||
|
};
|
||||||
|
resolvedByPhone.set(phone10, resolved);
|
||||||
|
leadsResolved++;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[MAINT] resolve failed for ${phone10}: ${err}`);
|
||||||
|
resolveErrors++;
|
||||||
|
callsSkipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullName = `${resolved.firstName} ${resolved.lastName}`.trim();
|
||||||
|
const updateParts: string[] = [];
|
||||||
|
if (needsLead && resolved.leadId) updateParts.push(`leadId: "${resolved.leadId}"`);
|
||||||
|
if (needsName && fullName) updateParts.push(`leadName: "${fullName.replace(/"/g, '\\"')}"`);
|
||||||
|
if (updateParts.length === 0) { callsSkipped++; continue; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation { updateCall(id: "${call.id}", data: { ${updateParts.join(', ')} }) { id } }`,
|
||||||
|
);
|
||||||
|
callsPatched++;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[MAINT] updateCall failed for ${call.id}: ${err}`);
|
||||||
|
callsSkipped++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throttle so the platform isn't hammered
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[MAINT] Backfill caller resolution complete: scanned=${callsScanned} patched=${callsPatched} skipped=${callsSkipped} uniquePhones=${resolvedByPhone.size} leadsResolved=${leadsResolved} resolveErrors=${resolveErrors}`);
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
calls: { scanned: callsScanned, patched: callsPatched, skipped: callsSkipped },
|
||||||
|
phones: { unique: resolvedByPhone.size, resolved: leadsResolved, errors: resolveErrors },
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,6 +171,18 @@ export class MasterdataService implements OnModuleInit {
|
|||||||
|
|
||||||
await this.cache.setCache(cacheKey, JSON.stringify(slots), CACHE_TTL);
|
await this.cache.setCache(cacheKey, JSON.stringify(slots), CACHE_TTL);
|
||||||
this.logger.log(`Generated ${slots.length} slots for doctor ${doctorId} on ${dayOfWeek}`);
|
this.logger.log(`Generated ${slots.length} slots for doctor ${doctorId} on ${dayOfWeek}`);
|
||||||
|
|
||||||
|
// Filter out past time slots when the requested date is today (IST).
|
||||||
|
// Cache stores the full day's slots — filtering happens post-cache
|
||||||
|
// so slots become available as the day progresses without cache churn.
|
||||||
|
const todayIST = new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Kolkata' });
|
||||||
|
if (date === todayIST) {
|
||||||
|
const nowHHMM = new Date().toLocaleTimeString('en-GB', { timeZone: 'Asia/Kolkata', hour: '2-digit', minute: '2-digit' });
|
||||||
|
const filtered = slots.filter(s => s.time >= nowHHMM);
|
||||||
|
this.logger.log(`[SLOTS] Today filter: ${slots.length} total → ${filtered.length} remaining (now=${nowHHMM} IST)`);
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
return slots;
|
return slots;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -394,6 +394,48 @@ export class OzonetelAgentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch a single CDR record by UCID. Preferred over fetchCDR + .find()
|
||||||
|
// for recording lookups — Ozonetel resolves leg-pair UCIDs internally,
|
||||||
|
// so the agent-side UCID we hold reliably returns the call row.
|
||||||
|
// Same rate limit as fetchCDR (2 req/min, 15-day window).
|
||||||
|
async fetchCdrByUCID(params: { date: string; ucid: string }): Promise<Record<string, any> | null> {
|
||||||
|
const url = `https://${this.apiDomain}/ca_reports/fetchCdrByUCID`;
|
||||||
|
this.logger.log(`Fetch CDR by UCID: ucid=${params.ucid} date=${params.date}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = await this.getToken();
|
||||||
|
const body = {
|
||||||
|
userName: this.accountId,
|
||||||
|
fromDate: `${params.date} 00:00:00`,
|
||||||
|
toDate: `${params.date} 23:59:59`,
|
||||||
|
ucid: params.ucid,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await axios({
|
||||||
|
method: 'GET',
|
||||||
|
url,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
data: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = response.data;
|
||||||
|
if (data.status === 'success' && Array.isArray(data.details) && data.details.length > 0) {
|
||||||
|
return data.details[0];
|
||||||
|
}
|
||||||
|
if (data.status === 'success' && data.details && !Array.isArray(data.details)) {
|
||||||
|
return data.details;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error: any) {
|
||||||
|
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
|
||||||
|
this.logger.error(`Fetch CDR by UCID failed: ${error.message} ${responseData}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getAgentSummary(agentId: string, date: string): Promise<{
|
async getAgentSummary(agentId: string, date: string): Promise<{
|
||||||
totalLoginDuration: string;
|
totalLoginDuration: string;
|
||||||
totalBusyTime: string;
|
totalBusyTime: string;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { WidgetController } from './widget.controller';
|
import { WidgetController } from './widget.controller';
|
||||||
import { WebhooksController } from './webhooks.controller';
|
import { WebhooksController } from './webhooks.controller';
|
||||||
import { WidgetService } from './widget.service';
|
import { WidgetService } from './widget.service';
|
||||||
@@ -6,12 +6,13 @@ import { WidgetChatService } from './widget-chat.service';
|
|||||||
import { PlatformModule } from '../platform/platform.module';
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { ConfigThemeModule } from '../config/config-theme.module';
|
import { ConfigThemeModule } from '../config/config-theme.module';
|
||||||
|
import { CallerResolutionModule } from '../caller/caller-resolution.module';
|
||||||
|
|
||||||
// WidgetKeysService lives in ConfigThemeModule now — injected here via the
|
// WidgetKeysService lives in ConfigThemeModule now — injected here via the
|
||||||
// module's exports. This module only owns the widget-facing API endpoints
|
// module's exports. This module only owns the widget-facing API endpoints
|
||||||
// (init / chat / book / lead) plus the NestJS guards that consume the keys.
|
// (init / chat / book / lead) plus the NestJS guards that consume the keys.
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule, AuthModule, ConfigThemeModule],
|
imports: [PlatformModule, AuthModule, ConfigThemeModule, forwardRef(() => CallerResolutionModule)],
|
||||||
controllers: [WidgetController, WebhooksController],
|
controllers: [WidgetController, WebhooksController],
|
||||||
providers: [WidgetService, WidgetChatService],
|
providers: [WidgetService, WidgetChatService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import type { WidgetInitResponse, WidgetBookRequest, WidgetLeadRequest } from './widget.types';
|
import type { WidgetInitResponse, WidgetBookRequest, WidgetLeadRequest } from './widget.types';
|
||||||
import { ThemeService } from '../config/theme.service';
|
import { ThemeService } from '../config/theme.service';
|
||||||
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors, type NormalizedDoctor } from '../shared/doctor-utils';
|
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors, type NormalizedDoctor } from '../shared/doctor-utils';
|
||||||
|
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||||
|
|
||||||
// Dedup window: any lead created for this phone within the last 24h is
|
// Dedup window: any lead created for this phone within the last 24h is
|
||||||
// considered the same visitor's lead — chat + book + contact by the same
|
// considered the same visitor's lead — chat + book + contact by the same
|
||||||
@@ -25,6 +26,7 @@ export class WidgetService {
|
|||||||
private platform: PlatformGraphqlService,
|
private platform: PlatformGraphqlService,
|
||||||
private theme: ThemeService,
|
private theme: ThemeService,
|
||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
|
private caller: CallerResolutionService,
|
||||||
) {
|
) {
|
||||||
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||||
}
|
}
|
||||||
@@ -37,8 +39,10 @@ export class WidgetService {
|
|||||||
return raw.replace(/[^0-9]/g, '').slice(-10);
|
return raw.replace(/[^0-9]/g, '').slice(-10);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared lead dedup: finds a lead created in the last 24h for the same
|
// Shared lead dedup. Resolves via CallerResolutionService; when isNew
|
||||||
// phone, or creates a new one. Public so WidgetChatService can reuse it.
|
// (no prior Lead/Patient), we have a name here (widget form field),
|
||||||
|
// so we create both records inline. When an existing record is
|
||||||
|
// returned we update it with the latest channel + name.
|
||||||
async findOrCreateLeadByPhone(
|
async findOrCreateLeadByPhone(
|
||||||
name: string,
|
name: string,
|
||||||
rawPhone: string,
|
rawPhone: string,
|
||||||
@@ -47,35 +51,31 @@ export class WidgetService {
|
|||||||
const phone = this.normalizePhone(rawPhone);
|
const phone = this.normalizePhone(rawPhone);
|
||||||
if (!phone) throw new Error('Invalid phone number');
|
if (!phone) throw new Error('Invalid phone number');
|
||||||
|
|
||||||
const since = new Date(Date.now() - LEAD_DEDUP_WINDOW_MS).toISOString();
|
const resolved = await this.caller.resolve(phone, this.auth);
|
||||||
|
const firstName = name.split(' ')[0] || name || 'Unknown';
|
||||||
try {
|
|
||||||
const existing = await this.platform.queryWithAuth<any>(
|
|
||||||
`query($phone: String!, $since: DateTime!) {
|
|
||||||
leads(
|
|
||||||
first: 1,
|
|
||||||
filter: {
|
|
||||||
contactPhone: { primaryPhoneNumber: { like: $phone } },
|
|
||||||
createdAt: { gte: $since }
|
|
||||||
},
|
|
||||||
orderBy: [{ createdAt: DescNullsLast }]
|
|
||||||
) { edges { node { id createdAt } } }
|
|
||||||
}`,
|
|
||||||
{ phone: `%${phone}`, since },
|
|
||||||
this.auth,
|
|
||||||
);
|
|
||||||
const match = existing?.leads?.edges?.[0]?.node;
|
|
||||||
if (match?.id) {
|
|
||||||
this.logger.log(`Lead dedup: reusing ${match.id} for phone ${phone}`);
|
|
||||||
return match.id as string;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(`Lead dedup lookup failed, falling through to create: ${err}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstName = name.split(' ')[0] || name;
|
|
||||||
const lastName = name.split(' ').slice(1).join(' ') || '';
|
const lastName = name.split(' ').slice(1).join(' ') || '';
|
||||||
|
|
||||||
|
if (resolved.isNew) {
|
||||||
|
// Net-new visitor — create Patient + Lead with the widget-
|
||||||
|
// collected name. Both records get the real name from the
|
||||||
|
// first moment they exist.
|
||||||
|
let patientId: string | undefined;
|
||||||
|
try {
|
||||||
|
const p = await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
fullName: { firstName, lastName },
|
||||||
|
phones: { primaryPhoneNumber: `+91${phone}` },
|
||||||
|
patientType: 'NEW',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
this.auth,
|
||||||
|
);
|
||||||
|
patientId = p?.createPatient?.id;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Widget patient create failed (${phone}): ${err}`);
|
||||||
|
}
|
||||||
const created = await this.platform.queryWithAuth<any>(
|
const created = await this.platform.queryWithAuth<any>(
|
||||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||||
{
|
{
|
||||||
@@ -86,14 +86,50 @@ export class WidgetService {
|
|||||||
source: opts.source ?? 'WEBSITE',
|
source: opts.source ?? 'WEBSITE',
|
||||||
status: opts.status ?? 'NEW',
|
status: opts.status ?? 'NEW',
|
||||||
interestedService: opts.interestedService ?? 'Website Enquiry',
|
interestedService: opts.interestedService ?? 'Website Enquiry',
|
||||||
|
...(patientId ? { patientId } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
this.auth,
|
this.auth,
|
||||||
);
|
);
|
||||||
const id = created?.createLead?.id;
|
const leadId = created?.createLead?.id;
|
||||||
if (!id) throw new Error('Lead creation returned no id');
|
if (!leadId) throw new Error('Lead creation returned no id');
|
||||||
this.logger.log(`Lead dedup: created ${id} for ${name} (${phone})`);
|
this.logger.log(`Widget lead created: ${leadId} (patient ${patientId ?? 'none'}) for ${name} (${phone})`);
|
||||||
return id as string;
|
return leadId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing Lead found — update with widget-supplied details.
|
||||||
|
const leadId = resolved.leadId;
|
||||||
|
try {
|
||||||
|
await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
id: leadId,
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
contactName: { firstName, lastName },
|
||||||
|
source: opts.source ?? 'WEBSITE',
|
||||||
|
status: opts.status ?? 'NEW',
|
||||||
|
interestedService: opts.interestedService ?? 'Website Enquiry',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
this.auth,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Lead update after resolve failed (lead=${leadId}): ${err}`);
|
||||||
|
}
|
||||||
|
if (resolved.patientId) {
|
||||||
|
try {
|
||||||
|
await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: resolved.patientId, data: { fullName: { firstName, lastName } } },
|
||||||
|
this.auth,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Patient rename after resolve failed (patient=${resolved.patientId}): ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.logger.log(`Widget lead updated: ${leadId} (patient ${resolved.patientId}) for ${name} (${phone})`);
|
||||||
|
return leadId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upgrade a lead's status — used when an existing lead is promoted from
|
// Upgrade a lead's status — used when an existing lead is promoted from
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Controller, Post, Body, Headers, Logger } from '@nestjs/common';
|
import { Controller, Post, Body, Headers, Logger } from '@nestjs/common';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
// Ozonetel sends all timestamps in IST — convert to UTC for storage
|
// Ozonetel sends all timestamps in IST — convert to UTC for storage
|
||||||
@@ -20,6 +21,7 @@ export class MissedCallWebhookController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly platform: PlatformGraphqlService,
|
private readonly platform: PlatformGraphqlService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
|
private readonly caller: CallerResolutionService,
|
||||||
) {
|
) {
|
||||||
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||||
}
|
}
|
||||||
@@ -73,7 +75,38 @@ export class MissedCallWebhookController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Create call record
|
// Step 1: Resolve caller. CallerResolutionService looks up BOTH
|
||||||
|
// leads and patients — for an existing patient with no lead yet
|
||||||
|
// it creates the lead on the fly and returns the name. This is
|
||||||
|
// the single source of truth for caller identity across webhook,
|
||||||
|
// polling, and agent-initiated paths.
|
||||||
|
let resolved: { leadId: string; leadName: string | null; patientId: string } = {
|
||||||
|
leadId: '',
|
||||||
|
leadName: null,
|
||||||
|
patientId: '',
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const r = await this.caller.resolve(callerPhone, authHeader);
|
||||||
|
const fullName = `${r.firstName} ${r.lastName}`.trim();
|
||||||
|
resolved = {
|
||||||
|
leadId: r.leadId,
|
||||||
|
// Resolver returns isNew when no Lead/Patient exists for
|
||||||
|
// this phone. We do NOT auto-create records from the
|
||||||
|
// webhook — agents don't have a name to attach, so we
|
||||||
|
// persist the phone as leadName (honest snapshot). The
|
||||||
|
// first agent action (enquiry, appointment) will create
|
||||||
|
// real Lead+Patient records and retroactive identity
|
||||||
|
// isn't a data-layer concern.
|
||||||
|
leadName: r.isNew ? `+91${callerPhone}` : (fullName || null),
|
||||||
|
patientId: r.patientId,
|
||||||
|
};
|
||||||
|
this.logger.log(`[WEBHOOK] Resolved ${callerPhone} → lead=${resolved.leadId || 'none'} name=${resolved.leadName ?? 'unresolved'} isNew=${r.isNew}`);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[WEBHOOK] Caller resolution failed for ${callerPhone}: ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Create call record with leadId + leadName baked in so
|
||||||
|
// the worklist row renders the patient name immediately.
|
||||||
const callId = await this.createCall({
|
const callId = await this.createCall({
|
||||||
callerPhone,
|
callerPhone,
|
||||||
direction,
|
direction,
|
||||||
@@ -85,25 +118,21 @@ export class MissedCallWebhookController {
|
|||||||
recordingUrl,
|
recordingUrl,
|
||||||
disposition,
|
disposition,
|
||||||
ucid,
|
ucid,
|
||||||
|
leadId: resolved.leadId || null,
|
||||||
|
leadName: resolved.leadName,
|
||||||
}, authHeader);
|
}, authHeader);
|
||||||
|
|
||||||
this.logger.log(`Created call record: ${callId} (${callStatus})`);
|
this.logger.log(`Created call record: ${callId} (${callStatus})${resolved.leadName ? ` linked to ${resolved.leadName}` : ''}`);
|
||||||
|
|
||||||
// Step 2: Find matching lead by phone number
|
// Step 3: Lead-side side-effects (activity log + contact stats)
|
||||||
const lead = await this.findLeadByPhone(callerPhone, authHeader);
|
if (resolved.leadId) {
|
||||||
|
|
||||||
if (lead) {
|
|
||||||
// Step 3: Link call to lead
|
|
||||||
await this.updateCall(callId, { leadId: lead.id }, authHeader);
|
|
||||||
|
|
||||||
// Step 4: Create lead activity
|
|
||||||
const summary = callStatus === 'MISSED'
|
const summary = callStatus === 'MISSED'
|
||||||
? `Missed inbound call from ${callerPhone} (${duration}s, ${hangupBy ?? 'unknown'})`
|
? `Missed inbound call from ${callerPhone} (${duration}s, ${hangupBy ?? 'unknown'})`
|
||||||
: `Inbound call from ${callerPhone} — ${duration}s, ${disposition || 'no disposition'}`;
|
: `Inbound call from ${callerPhone} — ${duration}s, ${disposition || 'no disposition'}`;
|
||||||
|
|
||||||
await this.createLeadActivity({
|
await this.createLeadActivity({
|
||||||
leadId: lead.id,
|
leadId: resolved.leadId,
|
||||||
activityType: callStatus === 'MISSED' ? 'CALL_RECEIVED' : 'CALL_RECEIVED',
|
activityType: 'CALL_RECEIVED',
|
||||||
summary,
|
summary,
|
||||||
channel: 'PHONE',
|
channel: 'PHONE',
|
||||||
performedBy: agentName ?? 'System',
|
performedBy: agentName ?? 'System',
|
||||||
@@ -111,18 +140,16 @@ export class MissedCallWebhookController {
|
|||||||
outcome: callStatus === 'MISSED' ? 'NO_ANSWER' : 'SUCCESSFUL',
|
outcome: callStatus === 'MISSED' ? 'NO_ANSWER' : 'SUCCESSFUL',
|
||||||
}, authHeader);
|
}, authHeader);
|
||||||
|
|
||||||
// Step 5: Update lead contact timestamps
|
// Bump contact timestamps. Read current contactAttempts first
|
||||||
await this.updateLead(lead.id, {
|
// (kept local rather than extending resolve() signature).
|
||||||
|
const leadMeta = await this.findLeadByPhone(callerPhone, authHeader);
|
||||||
|
await this.updateLead(resolved.leadId, {
|
||||||
lastContacted: startTime ? new Date(startTime).toISOString() : new Date().toISOString(),
|
lastContacted: startTime ? new Date(startTime).toISOString() : new Date().toISOString(),
|
||||||
contactAttempts: (lead.contactAttempts ?? 0) + 1,
|
contactAttempts: ((leadMeta?.contactAttempts) ?? 0) + 1,
|
||||||
}, authHeader);
|
}, authHeader);
|
||||||
|
|
||||||
this.logger.log(`Linked call to lead ${lead.id} (${lead.name}), activity created`);
|
|
||||||
} else {
|
|
||||||
this.logger.log(`No matching lead for ${callerPhone} — call record created without lead link`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { received: true, processed: true, callId, leadId: lead?.id ?? null };
|
return { received: true, processed: true, callId, leadId: resolved.leadId || null };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const responseData = err?.response?.data ? JSON.stringify(err.response.data) : '';
|
const responseData = err?.response?.data ? JSON.stringify(err.response.data) : '';
|
||||||
this.logger.error(`Webhook processing failed: ${err.message} ${responseData}`);
|
this.logger.error(`Webhook processing failed: ${err.message} ${responseData}`);
|
||||||
@@ -141,6 +168,8 @@ export class MissedCallWebhookController {
|
|||||||
recordingUrl: string | null;
|
recordingUrl: string | null;
|
||||||
disposition: string | null;
|
disposition: string | null;
|
||||||
ucid: string | null;
|
ucid: string | null;
|
||||||
|
leadId?: string | null;
|
||||||
|
leadName?: string | null;
|
||||||
}, authHeader: string): Promise<string> {
|
}, authHeader: string): Promise<string> {
|
||||||
const callData: Record<string, any> = {
|
const callData: Record<string, any> = {
|
||||||
name: `${data.direction === 'INBOUND' ? 'Inbound' : 'Outbound'} — ${data.callerPhone}`,
|
name: `${data.direction === 'INBOUND' ? 'Inbound' : 'Outbound'} — ${data.callerPhone}`,
|
||||||
@@ -153,6 +182,8 @@ export class MissedCallWebhookController {
|
|||||||
durationSec: data.duration,
|
durationSec: data.duration,
|
||||||
disposition: this.mapDisposition(data.disposition),
|
disposition: this.mapDisposition(data.disposition),
|
||||||
};
|
};
|
||||||
|
if (data.leadId) callData.leadId = data.leadId;
|
||||||
|
if (data.leadName) callData.leadName = data.leadName;
|
||||||
// Set callback tracking fields for missed calls so they appear in the worklist
|
// Set callback tracking fields for missed calls so they appear in the worklist
|
||||||
if (data.callStatus === 'MISSED') {
|
if (data.callStatus === 'MISSED') {
|
||||||
callData.callbackStatus = 'PENDING_CALLBACK';
|
callData.callbackStatus = 'PENDING_CALLBACK';
|
||||||
@@ -242,8 +273,9 @@ export class MissedCallWebhookController {
|
|||||||
'General Enquiry': 'INFO_PROVIDED',
|
'General Enquiry': 'INFO_PROVIDED',
|
||||||
'Appointment Booked': 'APPOINTMENT_BOOKED',
|
'Appointment Booked': 'APPOINTMENT_BOOKED',
|
||||||
'Follow Up': 'FOLLOW_UP_SCHEDULED',
|
'Follow Up': 'FOLLOW_UP_SCHEDULED',
|
||||||
'Not Interested': 'CALLBACK_REQUESTED',
|
'Not Interested': 'NOT_INTERESTED',
|
||||||
'Wrong Number': 'WRONG_NUMBER',
|
'Wrong Number': 'WRONG_NUMBER',
|
||||||
|
'No Answer': 'NO_ANSWER',
|
||||||
};
|
};
|
||||||
return map[disposition] ?? null;
|
return map[disposition] ?? null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
||||||
import { TelephonyConfigService } from '../config/telephony-config.service';
|
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||||
|
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||||
|
|
||||||
// Ozonetel sends all timestamps in IST — convert to UTC for storage
|
// Ozonetel sends all timestamps in IST — convert to UTC for storage
|
||||||
export function istToUtc(istDateStr: string | null): string | null {
|
export function istToUtc(istDateStr: string | null): string | null {
|
||||||
@@ -35,6 +36,7 @@ export class MissedQueueService implements OnModuleInit {
|
|||||||
private readonly platform: PlatformGraphqlService,
|
private readonly platform: PlatformGraphqlService,
|
||||||
private readonly ozonetel: OzonetelAgentService,
|
private readonly ozonetel: OzonetelAgentService,
|
||||||
private readonly telephony: TelephonyConfigService,
|
private readonly telephony: TelephonyConfigService,
|
||||||
|
private readonly caller: CallerResolutionService,
|
||||||
) {
|
) {
|
||||||
this.pollIntervalMs = this.config.get<number>('missedQueue.pollIntervalMs', 30000);
|
this.pollIntervalMs = this.config.get<number>('missedQueue.pollIntervalMs', 30000);
|
||||||
}
|
}
|
||||||
@@ -90,26 +92,29 @@ export class MissedQueueService implements OnModuleInit {
|
|||||||
const callTime = istToUtc(call.callTime) ?? new Date().toISOString();
|
const callTime = istToUtc(call.callTime) ?? new Date().toISOString();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Look up lead by phone number — strip +91 prefix for flexible matching
|
// Resolve caller via the shared service — covers the case
|
||||||
const phoneDigits = phone.replace(/^\+91/, '');
|
// where there's an existing patient but no lead yet (the
|
||||||
|
// service creates the lead on the fly and returns the name).
|
||||||
|
// Same source of truth as the webhook path.
|
||||||
let leadId: string | null = null;
|
let leadId: string | null = null;
|
||||||
let leadName: string | null = null;
|
let leadName: string | null = null;
|
||||||
try {
|
try {
|
||||||
const leadResult = await this.platform.query<any>(
|
const apiKey = this.config.get<string>('platform.apiKey') ?? '';
|
||||||
`{ leads(first: 1, filter: {
|
const auth = apiKey ? `Bearer ${apiKey}` : '';
|
||||||
contactPhone: { primaryPhoneNumber: { like: "%${phoneDigits}" } }
|
const r = await this.caller.resolve(phone, auth);
|
||||||
}) { edges { node { id contactName { firstName lastName } patientId } } } }`,
|
if (r.isNew) {
|
||||||
);
|
// No existing Lead/Patient — write phone as leadName.
|
||||||
const matchedLead = leadResult?.leads?.edges?.[0]?.node;
|
// Record creation is deferred to the first agent
|
||||||
if (matchedLead) {
|
// action (enquiry / appointment).
|
||||||
leadId = matchedLead.id;
|
leadName = phone;
|
||||||
const fn = matchedLead.contactName?.firstName ?? '';
|
} else if (r.leadId) {
|
||||||
const ln = matchedLead.contactName?.lastName ?? '';
|
leadId = r.leadId;
|
||||||
leadName = `${fn} ${ln}`.trim() || null;
|
const fullName = `${r.firstName} ${r.lastName}`.trim();
|
||||||
this.logger.log(`Matched missed call ${phone} → lead ${leadId} (${leadName})`);
|
leadName = fullName || null;
|
||||||
|
this.logger.log(`Matched missed call ${phone} → lead ${leadId} (${leadName ?? 'no name'})`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Lead lookup failed for ${phone}: ${err}`);
|
this.logger.warn(`Caller resolution failed for ${phone}: ${err}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await this.platform.query<any>(
|
const existing = await this.platform.query<any>(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { PlatformModule } from '../platform/platform.module';
|
|||||||
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { RulesEngineModule } from '../rules-engine/rules-engine.module';
|
import { RulesEngineModule } from '../rules-engine/rules-engine.module';
|
||||||
|
import { CallerResolutionModule } from '../caller/caller-resolution.module';
|
||||||
import { TelephonyConfigService } from '../config/telephony-config.service';
|
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||||
import { WorklistController } from './worklist.controller';
|
import { WorklistController } from './worklist.controller';
|
||||||
import { WorklistService } from './worklist.service';
|
import { WorklistService } from './worklist.service';
|
||||||
@@ -11,7 +12,7 @@ import { MissedCallWebhookController } from './missed-call-webhook.controller';
|
|||||||
import { KookooCallbackController } from './kookoo-callback.controller';
|
import { KookooCallbackController } from './kookoo-callback.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule), forwardRef(() => AuthModule), RulesEngineModule],
|
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule), forwardRef(() => AuthModule), RulesEngineModule, forwardRef(() => CallerResolutionModule)],
|
||||||
controllers: [WorklistController, MissedCallWebhookController, KookooCallbackController],
|
controllers: [WorklistController, MissedCallWebhookController, KookooCallbackController],
|
||||||
providers: [WorklistService, MissedQueueService, TelephonyConfigService],
|
providers: [WorklistService, MissedQueueService, TelephonyConfigService],
|
||||||
exports: [MissedQueueService],
|
exports: [MissedQueueService],
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
import { WorklistConsumer } from '../rules-engine/consumers/worklist.consumer';
|
import { WorklistConsumer } from '../rules-engine/consumers/worklist.consumer';
|
||||||
|
|
||||||
@@ -16,8 +17,49 @@ export class WorklistService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly platform: PlatformGraphqlService,
|
private readonly platform: PlatformGraphqlService,
|
||||||
private readonly worklistConsumer: WorklistConsumer,
|
private readonly worklistConsumer: WorklistConsumer,
|
||||||
|
private readonly config: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private get pageSize(): number {
|
||||||
|
return this.config.get<number>('worklist.pageSize', 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get maxPages(): number {
|
||||||
|
return this.config.get<number>('worklist.maxPages', 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paginate a Relay connection query. Caller provides a function that
|
||||||
|
// builds the query for a given cursor ('' on first page). Stops when
|
||||||
|
// the platform reports no more pages OR the safety ceiling hits.
|
||||||
|
private async fetchAllPages<T>(
|
||||||
|
buildQuery: (cursorClause: string) => string,
|
||||||
|
connectionKey: string,
|
||||||
|
authHeader: string,
|
||||||
|
): Promise<T[]> {
|
||||||
|
const all: T[] = [];
|
||||||
|
let cursor = '';
|
||||||
|
for (let page = 0; page < this.maxPages; page++) {
|
||||||
|
const cursorClause = cursor ? `, after: "${cursor}"` : '';
|
||||||
|
try {
|
||||||
|
const data = await this.platform.queryWithAuth<any>(
|
||||||
|
buildQuery(cursorClause),
|
||||||
|
undefined,
|
||||||
|
authHeader,
|
||||||
|
);
|
||||||
|
const conn = data?.[connectionKey];
|
||||||
|
if (!conn) break;
|
||||||
|
all.push(...(conn.edges?.map((e: any) => e.node) ?? []));
|
||||||
|
if (!conn.pageInfo?.hasNextPage) break;
|
||||||
|
cursor = conn.pageInfo.endCursor ?? '';
|
||||||
|
if (!cursor) break;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[WORKLIST] ${connectionKey} page ${page} failed: ${err}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
async getWorklist(agentName: string, authHeader: string): Promise<WorklistResponse> {
|
async getWorklist(agentName: string, authHeader: string): Promise<WorklistResponse> {
|
||||||
const [rawMissedCalls, rawFollowUps, rawMarketingLeads] = await Promise.all([
|
const [rawMissedCalls, rawFollowUps, rawMarketingLeads] = await Promise.all([
|
||||||
this.getMissedCalls(agentName, authHeader),
|
this.getMissedCalls(agentName, authHeader),
|
||||||
@@ -49,9 +91,8 @@ export class WorklistService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getAssignedLeads(agentName: string, authHeader: string): Promise<any[]> {
|
private async getAssignedLeads(agentName: string, authHeader: string): Promise<any[]> {
|
||||||
try {
|
return this.fetchAllPages<any>(
|
||||||
const data = await this.platform.queryWithAuth<any>(
|
(cursor) => `{ leads(first: ${this.pageSize}${cursor}, filter: { assignedAgent: { eq: "${agentName}" } }, orderBy: [{ createdAt: AscNullsLast }]) { edges { node {
|
||||||
`{ leads(first: 20, filter: { assignedAgent: { eq: "${agentName}" } }, orderBy: [{ createdAt: AscNullsLast }]) { edges { node {
|
|
||||||
id createdAt
|
id createdAt
|
||||||
contactName { firstName lastName }
|
contactName { firstName lastName }
|
||||||
contactPhone { primaryPhoneNumber }
|
contactPhone { primaryPhoneNumber }
|
||||||
@@ -60,58 +101,84 @@ export class WorklistService {
|
|||||||
assignedAgent campaignId
|
assignedAgent campaignId
|
||||||
contactAttempts spamScore isSpam
|
contactAttempts spamScore isSpam
|
||||||
aiSummary aiSuggestedAction
|
aiSummary aiSuggestedAction
|
||||||
} } } }`,
|
} } pageInfo { hasNextPage endCursor } } }`,
|
||||||
undefined,
|
'leads',
|
||||||
authHeader,
|
authHeader,
|
||||||
);
|
);
|
||||||
return data.leads.edges.map((e: any) => e.node);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(`Failed to fetch assigned leads: ${err}`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getPendingFollowUps(agentName: string, authHeader: string): Promise<any[]> {
|
private async getPendingFollowUps(agentName: string, authHeader: string): Promise<any[]> {
|
||||||
try {
|
const raw = await this.fetchAllPages<any>(
|
||||||
const data = await this.platform.queryWithAuth<any>(
|
(cursor) => `{ followUps(first: ${this.pageSize}${cursor}, filter: { assignedAgent: { eq: "${agentName}" } }) { edges { node {
|
||||||
`{ followUps(first: 20, filter: { assignedAgent: { eq: "${agentName}" } }) { edges { node {
|
|
||||||
id name createdAt
|
id name createdAt
|
||||||
typeCustom status scheduledAt completedAt
|
typeCustom status scheduledAt completedAt
|
||||||
priority assignedAgent
|
priority assignedAgent
|
||||||
patientId
|
patientId
|
||||||
|
} } pageInfo { hasNextPage endCursor } } }`,
|
||||||
|
'followUps',
|
||||||
|
authHeader,
|
||||||
|
);
|
||||||
|
// Filter to PENDING/OVERDUE client-side since platform may not support in-filter on remapped fields
|
||||||
|
const followUps = raw.filter((f: any) => f.status === 'PENDING' || f.status === 'OVERDUE');
|
||||||
|
try {
|
||||||
|
|
||||||
|
// Enrich with patient name/phone so the worklist can render them.
|
||||||
|
// FollowUp stores only patientId — the name in fu.name is free-form
|
||||||
|
// and phone isn't stored at all, so one patient fetch fills both.
|
||||||
|
const patientIds: string[] = Array.from(
|
||||||
|
new Set(followUps.map((f: any) => f.patientId).filter((id: any): id is string => typeof id === 'string' && id.length > 0)),
|
||||||
|
);
|
||||||
|
if (patientIds.length > 0) {
|
||||||
|
try {
|
||||||
|
const idsGql = patientIds.map((id) => `"${id}"`).join(',');
|
||||||
|
const patientsData = await this.platform.queryWithAuth<any>(
|
||||||
|
`{ patients(first: ${patientIds.length}, filter: { id: { in: [${idsGql}] } }) { edges { node {
|
||||||
|
id fullName { firstName lastName } phones { primaryPhoneNumber }
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined,
|
undefined,
|
||||||
authHeader,
|
authHeader,
|
||||||
);
|
);
|
||||||
// Filter to PENDING/OVERDUE client-side since platform may not support in-filter on remapped fields
|
const patientMap = new Map<string, { name: string; phone: string }>();
|
||||||
return data.followUps.edges
|
for (const edge of patientsData.patients.edges) {
|
||||||
.map((e: any) => e.node)
|
const p = edge.node;
|
||||||
.filter((f: any) => f.status === 'PENDING' || f.status === 'OVERDUE');
|
const name = [p.fullName?.firstName, p.fullName?.lastName].filter(Boolean).join(' ').trim();
|
||||||
|
const phone = p.phones?.primaryPhoneNumber ?? '';
|
||||||
|
patientMap.set(p.id, { name, phone });
|
||||||
|
}
|
||||||
|
for (const fu of followUps) {
|
||||||
|
if (fu.patientId && patientMap.has(fu.patientId)) {
|
||||||
|
const p = patientMap.get(fu.patientId)!;
|
||||||
|
fu.patientName = p.name;
|
||||||
|
fu.patientPhone = p.phone;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to enrich follow-ups with patient data: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return followUps;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Failed to fetch follow-ups: ${err}`);
|
this.logger.warn(`Failed to fetch follow-ups: ${err}`);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getMissedCalls(agentName: string, authHeader: string): Promise<any[]> {
|
private async getMissedCalls(_agentName: string, authHeader: string): Promise<any[]> {
|
||||||
try {
|
// FIFO ordering (AscNullsLast) — oldest first. No agentName filter —
|
||||||
// FIFO ordering (AscNullsLast) — oldest first. No agentName filter — missed calls are a shared queue.
|
// missed calls are a shared queue. Paginated via WORKLIST_PAGE_SIZE
|
||||||
const data = await this.platform.queryWithAuth<any>(
|
// × WORKLIST_MAX_PAGES ceiling.
|
||||||
`{ calls(first: 20, filter: { callStatus: { eq: MISSED }, callbackStatus: { in: [PENDING_CALLBACK, CALLBACK_ATTEMPTED] } }, orderBy: [{ startedAt: AscNullsLast }]) { edges { node {
|
return this.fetchAllPages<any>(
|
||||||
|
(cursor) => `{ calls(first: ${this.pageSize}${cursor}, filter: { callStatus: { eq: MISSED }, callbackStatus: { in: [PENDING_CALLBACK, CALLBACK_ATTEMPTED] } }, orderBy: [{ startedAt: AscNullsLast }]) { edges { node {
|
||||||
id name createdAt
|
id name createdAt
|
||||||
direction callStatus agentName
|
direction callStatus agentName
|
||||||
callerNumber { primaryPhoneNumber }
|
callerNumber { primaryPhoneNumber }
|
||||||
startedAt endedAt durationSec
|
startedAt endedAt durationSec
|
||||||
disposition leadId
|
disposition leadId leadName
|
||||||
callbackStatus callSourceNumber missedCallCount callbackAttemptedAt
|
callbackStatus callSourceNumber missedCallCount callbackAttemptedAt
|
||||||
} } } }`,
|
} } pageInfo { hasNextPage endCursor } } }`,
|
||||||
undefined,
|
'calls',
|
||||||
authHeader,
|
authHeader,
|
||||||
);
|
);
|
||||||
return data.calls.edges.map((e: any) => e.node);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(`Failed to fetch missed calls: ${err}`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user