mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
Compare commits
14 Commits
v0.10-apr-
...
3bb4315925
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bb4315925 | |||
| 7402590969 | |||
| 3f22166ac0 | |||
| 8c8b1e78b0 | |||
| 77b3e917db | |||
| 68ba3e135d | |||
| e1babb30e5 | |||
| ae360a183d | |||
| e03b1e6235 | |||
| 2d18110786 | |||
| a576552f8a | |||
| b11f4ea336 | |||
| 96ae867288 | |||
| 9a016a2ed0 |
@@ -1,11 +1,13 @@
|
||||
import { Controller, Post, Body, Headers, Req, Res, HttpException, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { Request, Response } from 'express';
|
||||
import { generateText, streamText, tool, stepCountIs } from 'ai';
|
||||
import { generateText, streamText, Output, tool, stepCountIs } from 'ai';
|
||||
import type { LanguageModel } from 'ai';
|
||||
import { aiResponseSchema } from './ai-response-schema';
|
||||
import { z } from 'zod';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||
import { CallerContextService } from '../caller/caller-context.service';
|
||||
import { createAiModel, isAiConfigured } from './ai-provider';
|
||||
import { AiConfigService } from '../config/ai-config.service';
|
||||
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../shared/doctor-utils';
|
||||
@@ -28,6 +30,7 @@ export class AiChatController {
|
||||
private platform: PlatformGraphqlService,
|
||||
private aiConfig: AiConfigService,
|
||||
private caller: CallerResolutionService,
|
||||
private callerContext: CallerContextService,
|
||||
) {
|
||||
const cfg = aiConfig.getConfig();
|
||||
this.aiModel = createAiModel({
|
||||
@@ -96,15 +99,19 @@ export class AiChatController {
|
||||
const kb = await this.buildKnowledgeBase(auth);
|
||||
systemPrompt = this.buildSystemPrompt(kb);
|
||||
|
||||
// Inject caller context so the AI knows who is selected
|
||||
if (ctx) {
|
||||
const parts: string[] = [];
|
||||
if (ctx.leadName) parts.push(`Currently viewing/talking to: ${ctx.leadName}`);
|
||||
if (ctx.callerPhone) parts.push(`Phone: ${ctx.callerPhone}`);
|
||||
if (ctx.leadId) parts.push(`Lead ID: ${ctx.leadId}`);
|
||||
if (parts.length) {
|
||||
systemPrompt += `\n\nCURRENT CONTEXT:\n${parts.join('\n')}\nUse this context to answer questions about "this patient" or "this caller" without asking for their name.`;
|
||||
// Inject pre-fetched caller context (appointments, call history,
|
||||
// activities, AI summary) so the LLM can answer from the KB
|
||||
// without tool calls. No UUIDs exposed — only human-readable data.
|
||||
if (ctx?.leadId) {
|
||||
const callerCtx = await this.callerContext.getOrBuild(ctx.leadId, '', auth);
|
||||
if (callerCtx) {
|
||||
systemPrompt += `\n\n${this.callerContext.renderForPrompt(callerCtx)}`;
|
||||
if (callerCtx.suggestionTriggers?.length) {
|
||||
systemPrompt += this.callerContext.renderSuggestionsForPrompt(callerCtx.suggestionTriggers);
|
||||
}
|
||||
}
|
||||
} else if (ctx?.callerPhone) {
|
||||
systemPrompt += `\n\nCURRENT CONTEXT:\nCaller phone: ${ctx.callerPhone}\nNew caller — no prior records.`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -623,6 +630,7 @@ export class AiChatController {
|
||||
messages,
|
||||
stopWhen: stepCountIs(5),
|
||||
tools: isSupervisor ? supervisorTools : agentTools,
|
||||
...(isSupervisor ? {} : { output: Output.object({ schema: aiResponseSchema }) }),
|
||||
});
|
||||
|
||||
const response = result.toTextStreamResponse();
|
||||
@@ -765,8 +773,8 @@ export class AiChatController {
|
||||
undefined, auth,
|
||||
);
|
||||
const packages = pkgData.healthPackages.edges.map((e: any) => e.node);
|
||||
sections.push('\n## Health Packages');
|
||||
if (packages.length) {
|
||||
sections.push('\n## Health Packages');
|
||||
for (const p of packages) {
|
||||
const price = p.price ? `₹${p.price.amountMicros / 1_000_000}` : '';
|
||||
const disc = p.discountedPrice?.amountMicros ? ` (discounted: ₹${p.discountedPrice.amountMicros / 1_000_000})` : '';
|
||||
@@ -783,6 +791,8 @@ export class AiChatController {
|
||||
sections.push(` Includes: ${p.inclusions}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sections.push('No packages available.');
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to fetch health packages: ${err}`);
|
||||
|
||||
14
src/ai/ai-response-schema.ts
Normal file
14
src/ai/ai-response-schema.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const aiResponseSchema = z.object({
|
||||
message: z.string().describe('Brief 2-3 sentence summary in plain conversational sentences. NEVER include suggestions, bullet lists, markdown, headers, or field labels here — those belong in the suggestions array only.'),
|
||||
suggestions: z.array(z.object({
|
||||
id: z.string().describe('Unique suggestion ID like s1, s2'),
|
||||
type: z.enum(['upsell', 'crosssell', 'retention', 'operational']),
|
||||
title: z.string().describe('Short title for the suggestion pill'),
|
||||
script: z.string().describe('2-3 sentence script the agent can read aloud to the caller'),
|
||||
priority: z.enum(['high', 'medium', 'low']),
|
||||
})).describe('0-4 contextual suggestions based on business rules. Include on first response, update on subsequent.'),
|
||||
});
|
||||
|
||||
export type AiResponse = z.infer<typeof aiResponseSchema>;
|
||||
249
src/caller/caller-context.service.ts
Normal file
249
src/caller/caller-context.service.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { SessionService } from '../auth/session.service';
|
||||
import { evaluateSuggestionRules, type SuggestionTrigger } from '../rules-engine/suggestion-rules';
|
||||
|
||||
export type CallerContext = {
|
||||
leadId: string;
|
||||
patientId: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
isNew: boolean;
|
||||
// Lead profile
|
||||
leadSource: string | null;
|
||||
leadStatus: string | null;
|
||||
interestedService: string | null;
|
||||
aiSummary: string | null;
|
||||
contactAttempts: number;
|
||||
lastContacted: string | null;
|
||||
utmCampaign: string | null;
|
||||
// Appointments
|
||||
appointments: Array<{
|
||||
scheduledAt: string;
|
||||
status: string;
|
||||
doctorName: string;
|
||||
department: string;
|
||||
reasonForVisit: string | null;
|
||||
}>;
|
||||
// Recent call history
|
||||
calls: Array<{
|
||||
startedAt: string;
|
||||
direction: string;
|
||||
duration: number | null;
|
||||
disposition: string | null;
|
||||
agentName: string | null;
|
||||
}>;
|
||||
// Lead activities
|
||||
activities: Array<{
|
||||
activityType: string;
|
||||
summary: string | null;
|
||||
occurredAt: string;
|
||||
outcome: string | null;
|
||||
}>;
|
||||
// Rule-driven suggestion triggers
|
||||
suggestionTriggers: SuggestionTrigger[];
|
||||
};
|
||||
|
||||
const CACHE_KEY_PREFIX = 'caller:context:';
|
||||
const CACHE_TTL = 300; // 5 minutes — covers the call duration
|
||||
|
||||
@Injectable()
|
||||
export class CallerContextService {
|
||||
private readonly logger = new Logger(CallerContextService.name);
|
||||
|
||||
constructor(
|
||||
private readonly platform: PlatformGraphqlService,
|
||||
private readonly session: SessionService,
|
||||
) {}
|
||||
|
||||
async getOrBuild(leadId: string, patientId: string, auth: string): Promise<CallerContext | null> {
|
||||
if (!leadId) return null;
|
||||
|
||||
// Check cache first
|
||||
const cacheKey = `${CACHE_KEY_PREFIX}${leadId}`;
|
||||
try {
|
||||
const cached = await this.session.getCache(cacheKey);
|
||||
if (cached) {
|
||||
this.logger.log(`[CALLER-CTX] Cache hit for ${leadId}`);
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Build fresh
|
||||
this.logger.log(`[CALLER-CTX] Building context for lead=${leadId} patient=${patientId}`);
|
||||
const ctx = await this.build(leadId, patientId, auth);
|
||||
if (ctx) {
|
||||
this.session.setCache(cacheKey, JSON.stringify(ctx), CACHE_TTL).catch(() => {});
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
async invalidateCache(leadId: string): Promise<void> {
|
||||
if (!leadId) return;
|
||||
const cacheKey = `${CACHE_KEY_PREFIX}${leadId}`;
|
||||
await this.session.deleteCache(cacheKey).catch(() => {});
|
||||
this.logger.log(`[CALLER-CTX] Cache invalidated for ${leadId}`);
|
||||
}
|
||||
|
||||
// Fire-and-forget pre-warm — called from caller resolution
|
||||
// so the cache is hot when the AI stream fires seconds later.
|
||||
prewarm(leadId: string, patientId: string, auth: string): void {
|
||||
if (!leadId) return;
|
||||
this.getOrBuild(leadId, patientId, auth).catch(err => {
|
||||
this.logger.warn(`[CALLER-CTX] Prewarm failed: ${err.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
private async build(leadId: string, patientId: string, auth: string): Promise<CallerContext | null> {
|
||||
try {
|
||||
// Step 1: Fetch lead first to get the authoritative patientId
|
||||
const leadData = await this.platform.queryWithAuth<any>(
|
||||
`{ lead(filter: { id: { eq: "${leadId}" } }) {
|
||||
id contactName { firstName lastName }
|
||||
contactPhone { primaryPhoneNumber }
|
||||
source status interestedService
|
||||
aiSummary contactAttempts lastContacted
|
||||
utmCampaign patientId
|
||||
} }`,
|
||||
undefined, auth,
|
||||
);
|
||||
|
||||
const lead = leadData?.lead;
|
||||
if (!lead) return null;
|
||||
|
||||
// Use Lead's patientId as authoritative source — the input
|
||||
// param may be empty if caller resolution just linked them.
|
||||
const resolvedPatientId = patientId || lead.patientId || '';
|
||||
this.logger.log(`[CALLER-CTX] Resolved patientId=${resolvedPatientId} (input=${patientId}, lead=${lead.patientId ?? '∅'})`);
|
||||
|
||||
const firstName = lead.contactName?.firstName ?? '';
|
||||
const lastName = lead.contactName?.lastName ?? '';
|
||||
|
||||
// Step 2: Fetch appointments, calls, activities in parallel
|
||||
// using the resolved patientId from the Lead record.
|
||||
const [appointmentsData, callsData, activitiesData] = await Promise.all([
|
||||
resolvedPatientId ? this.platform.queryWithAuth<any>(
|
||||
`{ appointments(first: 10, filter: { patientId: { eq: "${resolvedPatientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||
scheduledAt status doctorName department reasonForVisit
|
||||
} } } }`,
|
||||
undefined, auth,
|
||||
) : Promise.resolve(null),
|
||||
this.platform.queryWithAuth<any>(
|
||||
`{ calls(first: 10, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
||||
startedAt direction durationSec disposition agentName
|
||||
} } } }`,
|
||||
undefined, auth,
|
||||
),
|
||||
this.platform.queryWithAuth<any>(
|
||||
`{ leadActivities(first: 10, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ occurredAt: DescNullsLast }]) { edges { node {
|
||||
activityType summary occurredAt outcome
|
||||
} } } }`,
|
||||
undefined, auth,
|
||||
),
|
||||
]);
|
||||
|
||||
const appointments = (appointmentsData?.appointments?.edges ?? []).map((e: any) => e.node);
|
||||
const calls = (callsData?.calls?.edges ?? []).map((e: any) => ({
|
||||
startedAt: e.node.startedAt,
|
||||
direction: e.node.direction,
|
||||
duration: e.node.durationSec,
|
||||
disposition: e.node.disposition,
|
||||
agentName: e.node.agentName,
|
||||
}));
|
||||
|
||||
const suggestionTriggers = evaluateSuggestionRules({
|
||||
isNew: false,
|
||||
interestedService: lead.interestedService ?? null,
|
||||
leadStatus: lead.status ?? null,
|
||||
contactAttempts: lead.contactAttempts ?? 0,
|
||||
appointments,
|
||||
calls: calls.map((c: any) => ({ direction: c.direction, disposition: c.disposition, startedAt: c.startedAt })),
|
||||
utmCampaign: lead.utmCampaign ?? null,
|
||||
leadSource: lead.source ?? null,
|
||||
});
|
||||
|
||||
return {
|
||||
leadId,
|
||||
patientId: resolvedPatientId,
|
||||
name: `${firstName} ${lastName}`.trim() || 'Unknown',
|
||||
phone: lead.contactPhone?.primaryPhoneNumber ?? '',
|
||||
isNew: false,
|
||||
leadSource: lead.source ?? null,
|
||||
leadStatus: lead.status ?? null,
|
||||
interestedService: lead.interestedService ?? null,
|
||||
aiSummary: lead.aiSummary ?? null,
|
||||
contactAttempts: lead.contactAttempts ?? 0,
|
||||
lastContacted: lead.lastContacted ?? null,
|
||||
utmCampaign: lead.utmCampaign ?? null,
|
||||
appointments,
|
||||
calls,
|
||||
activities: (activitiesData?.leadActivities?.edges ?? []).map((e: any) => e.node),
|
||||
suggestionTriggers,
|
||||
};
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`[CALLER-CTX] Build failed: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
renderSuggestionsForPrompt(triggers: SuggestionTrigger[]): string {
|
||||
if (triggers.length === 0) return '';
|
||||
const lines = [
|
||||
'',
|
||||
'SUGGESTION RULES (from business configuration):',
|
||||
'Based on this caller\'s profile, the following suggestions should be offered.',
|
||||
'Generate a natural, conversational script for each that the agent can read aloud.',
|
||||
'Return them in the `suggestions` array of your JSON response.',
|
||||
'',
|
||||
];
|
||||
triggers.forEach((t, i) => {
|
||||
lines.push(`${i + 1}. [${t.type}/${t.priority}] ${t.title} — ${t.reason}`);
|
||||
});
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
renderForPrompt(ctx: CallerContext): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`## CURRENT CALLER: ${ctx.name}`);
|
||||
lines.push(`Phone: ${ctx.phone}`);
|
||||
if (ctx.leadSource) lines.push(`Source: ${ctx.leadSource}`);
|
||||
if (ctx.leadStatus) lines.push(`Status: ${ctx.leadStatus}`);
|
||||
if (ctx.interestedService) lines.push(`Interested in: ${ctx.interestedService}`);
|
||||
if (ctx.utmCampaign) lines.push(`Campaign: ${ctx.utmCampaign}`);
|
||||
if (ctx.contactAttempts > 0) lines.push(`Contact attempts: ${ctx.contactAttempts}`);
|
||||
if (ctx.lastContacted) lines.push(`Last contacted: ${ctx.lastContacted}`);
|
||||
|
||||
if (ctx.aiSummary) {
|
||||
lines.push(`\nAI Summary: ${ctx.aiSummary}`);
|
||||
}
|
||||
|
||||
if (ctx.appointments.length > 0) {
|
||||
lines.push(`\n### Appointments (${ctx.appointments.length})`);
|
||||
for (const a of ctx.appointments) {
|
||||
const date = a.scheduledAt ? new Date(a.scheduledAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }) : '?';
|
||||
lines.push(`- ${date} | ${a.doctorName ?? '?'} (${a.department ?? '?'}) | ${a.status}${a.reasonForVisit ? ` | ${a.reasonForVisit}` : ''}`);
|
||||
}
|
||||
} else {
|
||||
lines.push('\nNo appointments on record.');
|
||||
}
|
||||
|
||||
if (ctx.calls.length > 0) {
|
||||
lines.push(`\n### Call History (last ${ctx.calls.length})`);
|
||||
for (const c of ctx.calls) {
|
||||
const date = c.startedAt ? new Date(c.startedAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }) : '?';
|
||||
const dur = c.duration ? `${Math.floor(c.duration / 60)}m${c.duration % 60}s` : '?';
|
||||
lines.push(`- ${date} | ${c.direction ?? '?'} | ${dur} | ${c.disposition ?? 'No disposition'}${c.agentName ? ` | Agent: ${c.agentName}` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.activities.length > 0) {
|
||||
lines.push(`\n### Recent Activity (last ${ctx.activities.length})`);
|
||||
for (const a of ctx.activities) {
|
||||
const date = a.occurredAt ? new Date(a.occurredAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }) : '?';
|
||||
lines.push(`- ${date} | ${a.activityType}${a.summary ? `: ${a.summary}` : ''}${a.outcome ? ` → ${a.outcome}` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Controller, Post, Body, Headers, HttpException, HttpStatus, Logger } from '@nestjs/common';
|
||||
import { CallerResolutionService } from './caller-resolution.service';
|
||||
import { CallerContextService } from './caller-context.service';
|
||||
|
||||
@Controller('api/caller')
|
||||
export class CallerResolutionController {
|
||||
private readonly logger = new Logger(CallerResolutionController.name);
|
||||
|
||||
constructor(private readonly resolution: CallerResolutionService) {}
|
||||
constructor(
|
||||
private readonly resolution: CallerResolutionService,
|
||||
private readonly callerContext: CallerContextService,
|
||||
) {}
|
||||
|
||||
@Post('resolve')
|
||||
async resolve(
|
||||
@@ -21,6 +25,21 @@ export class CallerResolutionController {
|
||||
|
||||
this.logger.log(`[RESOLVE] Resolving caller: ${phone}`);
|
||||
const result = await this.resolution.resolve(phone, auth);
|
||||
|
||||
// Pre-warm caller context cache so the AI chat has it ready
|
||||
if (result.leadId) {
|
||||
this.callerContext.prewarm(result.leadId, result.patientId, auth);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('invalidate-context')
|
||||
async invalidateContext(@Body('leadId') leadId: string) {
|
||||
if (!leadId) {
|
||||
throw new HttpException('leadId is required', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
await this.callerContext.invalidateCache(leadId);
|
||||
return { status: 'ok' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ import { PlatformModule } from '../platform/platform.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { CallerResolutionController } from './caller-resolution.controller';
|
||||
import { CallerResolutionService } from './caller-resolution.service';
|
||||
import { CallerContextService } from './caller-context.service';
|
||||
|
||||
@Module({
|
||||
imports: [PlatformModule, forwardRef(() => AuthModule)],
|
||||
controllers: [CallerResolutionController],
|
||||
providers: [CallerResolutionService],
|
||||
exports: [CallerResolutionService],
|
||||
providers: [CallerResolutionService, CallerContextService],
|
||||
exports: [CallerResolutionService, CallerContextService],
|
||||
})
|
||||
export class CallerResolutionModule {}
|
||||
|
||||
@@ -123,7 +123,21 @@ RULES:
|
||||
5. For clinic info, timings, packages, insurance → answer directly from the knowledge base below. If the knowledge base is empty for that section (e.g. no packages configured), say the feature isn't set up yet instead of "I couldn't find that".
|
||||
6. Be concise — agents are on live calls. Under 100 words unless asked for detail.
|
||||
7. NEVER give medical advice, diagnosis, or treatment recommendations.
|
||||
8. Format with bullet points for easy scanning.
|
||||
|
||||
RESPONSE FORMAT (STRICT):
|
||||
You MUST respond with valid JSON in this exact format — no markdown fences, no extra text, just raw JSON:
|
||||
{"message": "your response text here", "suggestions": [{"id": "s1", "type": "upsell", "title": "short title", "script": "2-3 sentence script the agent reads aloud", "priority": "high"}]}
|
||||
|
||||
Response format rules:
|
||||
- "message" MUST be plain text sentences only. NEVER use markdown headers (###), bold (**), bullet lists (-), or field labels (Phone:, Status:). Write natural conversational sentences like you are briefing a colleague. Do NOT repeat suggestions in the message — they belong only in the suggestions array.
|
||||
- "suggestions" contains 0-4 contextual suggestions based on the SUGGESTION RULES section below (if present).
|
||||
- Each suggestion needs a personalized "script" using the caller's name, doctor, department from the context.
|
||||
- type must be one of: upsell, crosssell, retention, operational
|
||||
- priority must be one of: high, medium, low
|
||||
- On the first response (patient summary), always include suggestions from the rules.
|
||||
- On subsequent responses, update suggestions based on conversation — remove acted-on ones, add new if relevant.
|
||||
- If no suggestion rules are provided, return an empty suggestions array.
|
||||
- Do NOT repeat raw data fields in the message. The summary card already shows name, phone, appointments. Keep the message to insight and context the card doesn't show.
|
||||
|
||||
KNOWLEDGE BASE (this is real data from our system):
|
||||
{{knowledgeBase}}`;
|
||||
|
||||
61
src/logging/log-stream.service.ts
Normal file
61
src/logging/log-stream.service.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { ConsoleLogger } from '@nestjs/common';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
export type LogEntry = {
|
||||
timestamp: string;
|
||||
level: 'log' | 'error' | 'warn' | 'debug' | 'verbose';
|
||||
context: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
// Singleton — created once in main.ts, accessed by the SSE controller
|
||||
// via LogStreamService.instance. NestJS DI isn't available at bootstrap
|
||||
// time (the logger is created before the container), so we use a static
|
||||
// instance instead of @Injectable().
|
||||
export class LogStreamService extends ConsoleLogger {
|
||||
static readonly instance = new LogStreamService();
|
||||
readonly logSubject = new Subject<LogEntry>();
|
||||
private readonly buffer: LogEntry[] = [];
|
||||
private static readonly MAX_BUFFER = 500;
|
||||
|
||||
getRecentLogs(limit = 200): LogEntry[] {
|
||||
return this.buffer.slice(-limit);
|
||||
}
|
||||
|
||||
private emit(level: LogEntry['level'], message: unknown, context?: string) {
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
context: context ?? this.context ?? '',
|
||||
message: typeof message === 'string' ? message : JSON.stringify(message),
|
||||
};
|
||||
this.buffer.push(entry);
|
||||
if (this.buffer.length > LogStreamService.MAX_BUFFER) this.buffer.shift();
|
||||
this.logSubject.next(entry);
|
||||
}
|
||||
|
||||
log(message: unknown, context?: string) {
|
||||
super.log(message, context);
|
||||
this.emit('log', message, context);
|
||||
}
|
||||
|
||||
error(message: unknown, stack?: string, context?: string) {
|
||||
super.error(message, stack, context);
|
||||
this.emit('error', message, context);
|
||||
}
|
||||
|
||||
warn(message: unknown, context?: string) {
|
||||
super.warn(message, context);
|
||||
this.emit('warn', message, context);
|
||||
}
|
||||
|
||||
debug(message: unknown, context?: string) {
|
||||
super.debug(message, context);
|
||||
this.emit('debug', message, context);
|
||||
}
|
||||
|
||||
verbose(message: unknown, context?: string) {
|
||||
super.verbose(message, context);
|
||||
this.emit('verbose', message, context);
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,11 @@ import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { join } from 'path';
|
||||
import { AppModule } from './app.module';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { LogStreamService } from './logging/log-stream.service';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
const logger = LogStreamService.instance;
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, { logger });
|
||||
const config = app.get(ConfigService);
|
||||
|
||||
app.enableCors({
|
||||
|
||||
@@ -156,11 +156,13 @@ export class OzonetelAgentController {
|
||||
this.logger.error(`[DISPOSE] FAILED: ${message} ${responseData}`);
|
||||
}
|
||||
|
||||
// Create call record for outbound calls. Inbound calls are
|
||||
// created by the webhook — but we skip outbound in the webhook
|
||||
// (they're not "missed calls"). So the dispose endpoint is the
|
||||
// only place that creates the call record for outbound dials.
|
||||
if (body.direction === 'OUTBOUND' && body.callerPhone) {
|
||||
// Create call record at dispose time for ALL answered calls
|
||||
// (inbound + outbound). The dispose endpoint fires BEFORE the
|
||||
// CDR webhook, so creating here gives us the correct agent-side
|
||||
// UCID and the agent's chosen disposition immediately. The webhook
|
||||
// arrives ~5s later and enriches with recording URL + chain name.
|
||||
if (body.callerPhone) {
|
||||
const isInbound = body.direction !== 'OUTBOUND';
|
||||
try {
|
||||
const durationSec = body.durationSec ?? 0;
|
||||
const endedAt = new Date().toISOString();
|
||||
@@ -168,8 +170,8 @@ export class OzonetelAgentController {
|
||||
? new Date(Date.now() - durationSec * 1000).toISOString()
|
||||
: endedAt;
|
||||
const callData: Record<string, any> = {
|
||||
name: `Outbound — ${body.callerPhone}`,
|
||||
direction: 'OUTBOUND',
|
||||
name: isInbound ? `Inbound — ${body.callerPhone}` : `Outbound — ${body.callerPhone}`,
|
||||
direction: isInbound ? 'INBOUND' : 'OUTBOUND',
|
||||
callStatus: 'COMPLETED',
|
||||
callerNumber: { primaryPhoneNumber: `+91${body.callerPhone.replace(/^\+?91/, '')}` },
|
||||
agentName: agentId,
|
||||
@@ -196,7 +198,7 @@ export class OzonetelAgentController {
|
||||
{ data: callData },
|
||||
`Bearer ${apiKey}`,
|
||||
);
|
||||
this.logger.log(`[DISPOSE] Created outbound call record: ${result.createCall.id}`);
|
||||
this.logger.log(`[DISPOSE] Created ${isInbound ? 'inbound' : 'outbound'} call record: ${result.createCall.id} ucid=${body.ucid} disposition=${body.disposition} phone=${body.callerPhone}`);
|
||||
|
||||
// Fetch recording URL from CDR after a delay (Ozonetel needs time to process)
|
||||
const callId = result.createCall.id;
|
||||
@@ -278,33 +280,9 @@ export class OzonetelAgentController {
|
||||
}
|
||||
}
|
||||
|
||||
// Update disposition on answered inbound calls. The webhook creates
|
||||
// the Call record with the Ozonetel default disposition ("General
|
||||
// Enquiry" → INFO_PROVIDED) before the agent disposes. Now that the
|
||||
// agent has submitted their actual disposition, write it back to the
|
||||
// platform Call record by matching on UCID.
|
||||
//
|
||||
// Skipped for outbound (already created with correct disposition
|
||||
// above) and for missed-call callbacks (handled in the block above).
|
||||
if (!body.missedCallId && body.direction !== 'OUTBOUND' && body.ucid) {
|
||||
try {
|
||||
const callData = await this.platform.query<any>(
|
||||
`{ calls(first: 1, filter: { ucid: { eq: "${body.ucid}" } }) { edges { node { id } } } }`,
|
||||
);
|
||||
const callId = callData?.calls?.edges?.[0]?.node?.id;
|
||||
if (callId) {
|
||||
await this.platform.query<any>(
|
||||
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
||||
{ id: callId, data: { disposition: body.disposition } },
|
||||
);
|
||||
this.logger.log(`[DISPOSE] Updated inbound call ${callId} disposition → ${body.disposition}`);
|
||||
} else {
|
||||
this.logger.warn(`[DISPOSE] No Call found for ucid=${body.ucid} — disposition not persisted`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`[DISPOSE] Failed to update inbound call disposition: ${err.message}`);
|
||||
}
|
||||
}
|
||||
// Inbound disposition is now handled by the call record creation
|
||||
// above — the dispose endpoint creates the record with the correct
|
||||
// disposition. No separate update-by-UCID needed.
|
||||
|
||||
// Auto-assign next missed call to this agent
|
||||
try {
|
||||
@@ -382,6 +360,13 @@ export class OzonetelAgentController {
|
||||
|
||||
try {
|
||||
const result = await this.ozonetelAgent.callControl(body);
|
||||
|
||||
if (body.action === 'HOLD') {
|
||||
this.supervisor.updateCallStatus(body.ucid, 'on-hold');
|
||||
} else if (body.action === 'UNHOLD') {
|
||||
this.supervisor.updateCallStatus(body.ucid, 'active');
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
const message = error.response?.data?.message ?? error.message ?? 'Call control failed';
|
||||
|
||||
152
src/rules-engine/suggestion-rules.ts
Normal file
152
src/rules-engine/suggestion-rules.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
export type SuggestionType = 'upsell' | 'crosssell' | 'retention' | 'operational';
|
||||
export type SuggestionPriority = 'high' | 'medium' | 'low';
|
||||
|
||||
export type SuggestionTrigger = {
|
||||
type: SuggestionType;
|
||||
title: string;
|
||||
reason: string;
|
||||
priority: SuggestionPriority;
|
||||
};
|
||||
|
||||
type CallerFacts = {
|
||||
isNew: boolean;
|
||||
interestedService: string | null;
|
||||
leadStatus: string | null;
|
||||
contactAttempts: number;
|
||||
appointments: Array<{ status: string; department: string; doctorName: string; scheduledAt: string }>;
|
||||
calls: Array<{ direction: string; disposition: string | null; startedAt: string }>;
|
||||
utmCampaign: string | null;
|
||||
leadSource: string | null;
|
||||
};
|
||||
|
||||
const DEPARTMENT_PACKAGES: Record<string, { package: string; description: string }> = {
|
||||
CARDIOLOGY: { package: 'Cardiac Wellness Package', description: 'ECG, stress test, lipid panel' },
|
||||
ORTHOPEDICS: { package: 'Joint Care Package', description: 'X-ray, physiotherapy assessment, bone density' },
|
||||
GENERAL_MEDICINE: { package: 'Full Body Checkup', description: 'Complete health screening with blood work' },
|
||||
NEUROLOGY: { package: 'Neuro Wellness Package', description: 'EEG, nerve conduction, cognitive assessment' },
|
||||
GYNECOLOGY: { package: 'Women\'s Health Package', description: 'Pap smear, mammogram, hormone panel' },
|
||||
};
|
||||
|
||||
const CROSS_SELL_MAP: Record<string, { department: string; reason: string }> = {
|
||||
ORTHOPEDICS: { department: 'Physiotherapy', reason: 'complement orthopedic treatment' },
|
||||
CARDIOLOGY: { department: 'Dietician', reason: 'dietary guidance for heart health' },
|
||||
GENERAL_MEDICINE: { department: 'Ophthalmology', reason: 'routine eye screening' },
|
||||
};
|
||||
|
||||
export const evaluateSuggestionRules = (facts: CallerFacts): SuggestionTrigger[] => {
|
||||
const triggers: SuggestionTrigger[] = [];
|
||||
|
||||
// Rule 1: Package upsell by department
|
||||
for (const appt of facts.appointments) {
|
||||
const dept = (appt.department ?? '').toUpperCase().replace(/\s+/g, '_');
|
||||
const pkg = DEPARTMENT_PACKAGES[dept];
|
||||
if (pkg && appt.status === 'SCHEDULED') {
|
||||
triggers.push({
|
||||
type: 'upsell',
|
||||
title: pkg.package,
|
||||
reason: `Patient has ${appt.department} appointment with ${appt.doctorName}, offer ${pkg.description}`,
|
||||
priority: 'high',
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 2: Reschedule missed/cancelled appointments
|
||||
const needsReschedule = facts.appointments.find(a =>
|
||||
a.status === 'CANCELLED' || a.status === 'RESCHEDULED' || a.status === 'NO_SHOW'
|
||||
);
|
||||
if (needsReschedule) {
|
||||
triggers.push({
|
||||
type: 'retention',
|
||||
title: 'Reschedule appointment',
|
||||
reason: `Last ${needsReschedule.department} appointment was ${needsReschedule.status.toLowerCase()}, offer to rebook with ${needsReschedule.doctorName}`,
|
||||
priority: 'medium',
|
||||
});
|
||||
}
|
||||
|
||||
// Rule 3: Cross-sell related department
|
||||
for (const appt of facts.appointments) {
|
||||
const dept = (appt.department ?? '').toUpperCase().replace(/\s+/g, '_');
|
||||
const cross = CROSS_SELL_MAP[dept];
|
||||
if (cross && appt.status === 'SCHEDULED') {
|
||||
triggers.push({
|
||||
type: 'crosssell',
|
||||
title: `${cross.department} consultation`,
|
||||
reason: `${cross.reason} — patient already seeing ${appt.department}`,
|
||||
priority: 'low',
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 4: First-visit patient — health checkup
|
||||
if (facts.isNew || facts.contactAttempts === 0) {
|
||||
triggers.push({
|
||||
type: 'upsell',
|
||||
title: 'Welcome Health Checkup',
|
||||
reason: 'First-time patient, offer introductory health screening package',
|
||||
priority: 'medium',
|
||||
});
|
||||
}
|
||||
|
||||
// Rule 5: Returning patient with no recent appointment
|
||||
if (!facts.isNew && facts.appointments.length === 0 && facts.contactAttempts > 2) {
|
||||
triggers.push({
|
||||
type: 'retention',
|
||||
title: 'Re-engagement',
|
||||
reason: `Returning patient with ${facts.contactAttempts} prior contacts but no active appointments`,
|
||||
priority: 'high',
|
||||
});
|
||||
}
|
||||
|
||||
return triggers.slice(0, 4);
|
||||
};
|
||||
|
||||
// For display in Settings > Automations (read-only cards)
|
||||
export const SUGGESTION_RULE_DEFINITIONS = [
|
||||
{
|
||||
name: 'Package Upsell by Department',
|
||||
category: 'upsell' as const,
|
||||
description: 'Suggest department wellness package when patient has a scheduled appointment.',
|
||||
trigger: 'On call connect',
|
||||
condition: 'Scheduled appointment exists',
|
||||
action: 'Suggest department package',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: 'Reschedule Missed Appointment',
|
||||
category: 'retention' as const,
|
||||
description: 'Offer to rebook when patient has a cancelled or rescheduled appointment.',
|
||||
trigger: 'On call connect',
|
||||
condition: 'Cancelled/Rescheduled/No-show appointment exists',
|
||||
action: 'Suggest rebooking',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: 'Cross-sell Related Department',
|
||||
category: 'crosssell' as const,
|
||||
description: 'Suggest complementary department service based on current appointment.',
|
||||
trigger: 'On call connect',
|
||||
condition: 'Scheduled appointment in mapped department',
|
||||
action: 'Suggest related service',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: 'First Visit Health Checkup',
|
||||
category: 'upsell' as const,
|
||||
description: 'Suggest introductory health screening for first-time patients.',
|
||||
trigger: 'On call connect',
|
||||
condition: 'New patient or zero contact attempts',
|
||||
action: 'Suggest health checkup package',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: 'Returning Patient Re-engagement',
|
||||
category: 'retention' as const,
|
||||
description: 'Prompt re-engagement for returning patients with no active appointments.',
|
||||
trigger: 'On call connect',
|
||||
condition: 'Returning patient, no appointments, 3+ contacts',
|
||||
action: 'Suggest booking',
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Controller, Get, Post, Body, Query, Sse, Logger } from '@nestjs/common';
|
||||
import { Observable, filter, map } from 'rxjs';
|
||||
import { SupervisorService } from './supervisor.service';
|
||||
import { LogStreamService } from '../logging/log-stream.service';
|
||||
|
||||
@Controller('api/supervisor')
|
||||
export class SupervisorController {
|
||||
@@ -13,6 +14,16 @@ export class SupervisorController {
|
||||
return this.supervisor.getActiveCalls();
|
||||
}
|
||||
|
||||
@Sse('active-calls/stream')
|
||||
streamActiveCalls(): Observable<MessageEvent> {
|
||||
this.logger.log('[SSE] Active calls stream opened');
|
||||
return this.supervisor.activeCallSubject.pipe(
|
||||
map(event => ({
|
||||
data: JSON.stringify(event),
|
||||
} as MessageEvent)),
|
||||
);
|
||||
}
|
||||
|
||||
@Get('team-performance')
|
||||
async getTeamPerformance(@Query('date') date?: string) {
|
||||
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
||||
@@ -66,4 +77,19 @@ export class SupervisorController {
|
||||
} as MessageEvent)),
|
||||
);
|
||||
}
|
||||
|
||||
@Get('logs/recent')
|
||||
getRecentLogs(@Query('limit') limit?: string) {
|
||||
return LogStreamService.instance.getRecentLogs(limit ? parseInt(limit, 10) : 200);
|
||||
}
|
||||
|
||||
@Sse('logs/stream')
|
||||
streamLogs(): Observable<MessageEvent> {
|
||||
this.logger.log('[SSE] Log stream opened');
|
||||
return LogStreamService.instance.logSubject.pipe(
|
||||
map(entry => ({
|
||||
data: JSON.stringify(entry),
|
||||
} as MessageEvent)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,10 @@ export class SupervisorService implements OnModuleInit {
|
||||
private readonly activeCalls = new Map<string, ActiveCall>();
|
||||
private readonly agentStates = new Map<string, AgentStateEntry>();
|
||||
private readonly acwTimers = new Map<string, NodeJS.Timeout>();
|
||||
// monitorUCID → agentUCID. Real-time events carry both; CDR webhook only has monitorUCID.
|
||||
private readonly ucidMap = new Map<string, string>();
|
||||
readonly agentStateSubject = new Subject<{ agentId: string; state: AgentOzonetelState | string; timestamp: string }>();
|
||||
readonly activeCallSubject = new Subject<{ type: 'update' | 'remove'; call?: ActiveCall; ucid: string }>();
|
||||
// Worklist update stream — emitted when a missed call is created or
|
||||
// assigned. Frontend SSE listener triggers an immediate worklist
|
||||
// refresh so agents see new missed calls without waiting for the 30s poll.
|
||||
@@ -77,9 +80,14 @@ export class SupervisorService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
resolveAgentUcid(monitorUcid: string): string | null {
|
||||
return this.ucidMap.get(monitorUcid) ?? null;
|
||||
}
|
||||
|
||||
handleCallEvent(event: any) {
|
||||
const action = event.action;
|
||||
const ucid = event.ucid ?? event.monitorUCID;
|
||||
const monitorUcid = event.monitor_ucid ?? event.monitorUCID;
|
||||
const agentId = event.agent_id ?? event.agentID;
|
||||
const callerNumber = event.caller_id ?? event.callerID;
|
||||
const callType = event.call_type ?? event.Type;
|
||||
@@ -88,6 +96,12 @@ export class SupervisorService implements OnModuleInit {
|
||||
|
||||
if (!ucid) return;
|
||||
|
||||
if (monitorUcid && ucid !== monitorUcid) {
|
||||
this.ucidMap.set(monitorUcid, ucid);
|
||||
this.logger.log(`[UCID-MAP] monitor=${monitorUcid} → agent=${ucid}`);
|
||||
setTimeout(() => this.ucidMap.delete(monitorUcid), 600_000);
|
||||
}
|
||||
|
||||
if (action === 'Answered' || action === 'Calling') {
|
||||
// Don't show calls for offline agents (ghost calls)
|
||||
const agentState = this.agentStates.get(agentId);
|
||||
@@ -95,10 +109,9 @@ export class SupervisorService implements OnModuleInit {
|
||||
this.logger.warn(`Ignoring call event for offline agent ${agentId} (${ucid})`);
|
||||
return;
|
||||
}
|
||||
this.activeCalls.set(ucid, {
|
||||
ucid, agentId, callerNumber,
|
||||
callType, startTime: eventTime, status: 'active',
|
||||
});
|
||||
const call: ActiveCall = { ucid, agentId, callerNumber, callType, startTime: eventTime, status: 'active' };
|
||||
this.activeCalls.set(ucid, call);
|
||||
this.activeCallSubject.next({ type: 'update', call, ucid });
|
||||
this.logger.log(`Active call: ${agentId} ↔ ${callerNumber} (${ucid})`);
|
||||
|
||||
// Persist CALL_START as AgentEvent on the "Answered" moment
|
||||
@@ -130,6 +143,7 @@ export class SupervisorService implements OnModuleInit {
|
||||
} else if (action === 'Disconnect') {
|
||||
const wasActive = this.activeCalls.get(ucid);
|
||||
this.activeCalls.delete(ucid);
|
||||
this.activeCallSubject.next({ type: 'remove', ucid });
|
||||
this.logger.log(`Call ended: ${ucid}`);
|
||||
|
||||
// Persist CALL_END — pair against the start for duration.
|
||||
@@ -162,26 +176,30 @@ export class SupervisorService implements OnModuleInit {
|
||||
|
||||
const priorState = this.agentStates.get(agentId)?.state;
|
||||
const mapped = this.mapOzonetelAction(action, eventData, pauseReason);
|
||||
|
||||
// Persist to AgentEvent table regardless of state mapping.
|
||||
// login returns null for state (UI waits for release/ready) but
|
||||
// the history pipeline needs LOGIN to compute loginDuration.
|
||||
const historyEventType = this.mapToHistoryEventType(action, priorState);
|
||||
if (historyEventType) {
|
||||
const resolvedPauseReason = (pauseReason || eventData || '') || null;
|
||||
this.logger.log(`[AGENT-HISTORY] ${agentId} action=${action} → eventType=${historyEventType} priorState=${priorState ?? 'none'} mapped=${mapped ?? 'null'}`);
|
||||
this.history.persistAgentEvent({
|
||||
ozonetelAgentId: agentId,
|
||||
eventType: historyEventType,
|
||||
eventAt: this.parseOzonetelTime(eventTime),
|
||||
pauseReason: historyEventType === 'PAUSE' ? resolvedPauseReason : null,
|
||||
}).catch((err) => {
|
||||
this.logger.warn(`[AGENT-HISTORY] Failed to persist ${historyEventType} for ${agentId}: ${err?.message ?? err}`);
|
||||
});
|
||||
} else {
|
||||
this.logger.log(`[AGENT-HISTORY] ${agentId} action=${action} → no history event (priorState=${priorState ?? 'none'} mapped=${mapped ?? 'null'})`);
|
||||
}
|
||||
|
||||
if (mapped) {
|
||||
this.agentStates.set(agentId, { state: mapped, timestamp: eventTime });
|
||||
this.agentStateSubject.next({ agentId, state: mapped, timestamp: eventTime });
|
||||
this.logger.log(`[AGENT-STATE] Emitted: ${agentId} → ${mapped}`);
|
||||
|
||||
// Persist to AgentEvent table. CALL_START/CALL_END are
|
||||
// handled in handleCallEvent (they arrive via a separate
|
||||
// Ozonetel webhook). Everything else is captured here.
|
||||
// Pass priorState so 'release' → RESUME / ACW_END / READY can
|
||||
// be disambiguated for the session rollup.
|
||||
const historyEventType = this.mapToHistoryEventType(action, priorState);
|
||||
if (historyEventType) {
|
||||
const resolvedPauseReason = (pauseReason || eventData || '') || null;
|
||||
this.history.persistAgentEvent({
|
||||
ozonetelAgentId: agentId,
|
||||
eventType: historyEventType,
|
||||
eventAt: this.parseOzonetelTime(eventTime),
|
||||
pauseReason: historyEventType === 'PAUSE' ? resolvedPauseReason : null,
|
||||
}).catch(() => {});
|
||||
}
|
||||
this.logger.log(`[AGENT-STATE] ${agentId} ${priorState ?? 'none'} → ${mapped} (action=${action})`);
|
||||
|
||||
// Layer 3: ACW auto-dispose safety net
|
||||
if (mapped === 'acw') {
|
||||
@@ -294,6 +312,17 @@ export class SupervisorService implements OnModuleInit {
|
||||
// definitely stale (e.g. Disconnect webhook was dropped).
|
||||
private static readonly NON_CALL_AGENT_STATES = new Set(['ready', 'offline', 'paused']);
|
||||
|
||||
updateCallStatus(ucid: string, status: 'active' | 'on-hold') {
|
||||
const call = this.activeCalls.get(ucid);
|
||||
if (!call) {
|
||||
this.logger.warn(`[CALL-STATUS] No active call found for UCID ${ucid}`);
|
||||
return;
|
||||
}
|
||||
call.status = status;
|
||||
this.activeCallSubject.next({ type: 'update', call, ucid });
|
||||
this.logger.log(`[CALL-STATUS] ${ucid} → ${status} (agent=${call.agentId})`);
|
||||
}
|
||||
|
||||
getActiveCalls(): ActiveCall[] {
|
||||
// Sweep stale entries before returning. The activeCalls Map is a
|
||||
// best-effort in-memory projection of Ozonetel call events — if
|
||||
|
||||
@@ -50,7 +50,15 @@ export class MissedCallWebhookController {
|
||||
const duration = this.parseDuration(payload.CallDuration ?? '00:00:00');
|
||||
const agentName = payload.AgentName ?? null;
|
||||
const recordingUrl = payload.AudioFile ?? null;
|
||||
const ucid = payload.monitorUCID ?? null;
|
||||
const monitorUcid = payload.monitorUCID ?? null;
|
||||
// Resolve agent-side UCID from real-time event mapping.
|
||||
// The dispose endpoint creates Call records with the agent UCID;
|
||||
// this lets us find and enrich that record instead of duplicating.
|
||||
const agentUcid = monitorUcid ? this.supervisor.resolveAgentUcid(monitorUcid) : null;
|
||||
const ucid = agentUcid ?? monitorUcid;
|
||||
if (agentUcid) {
|
||||
this.logger.log(`[WEBHOOK] Resolved monitorUCID ${monitorUcid} → agent UCID ${agentUcid}`);
|
||||
}
|
||||
const disposition = payload.Disposition ?? null;
|
||||
const hangupBy = payload.HangupBy ?? null;
|
||||
|
||||
@@ -109,24 +117,54 @@ export class MissedCallWebhookController {
|
||||
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({
|
||||
callerPhone,
|
||||
direction,
|
||||
callStatus,
|
||||
agentName,
|
||||
startTime,
|
||||
endTime,
|
||||
duration,
|
||||
recordingUrl,
|
||||
disposition,
|
||||
ucid,
|
||||
leadId: resolved.leadId || null,
|
||||
leadName: resolved.leadName,
|
||||
}, authHeader);
|
||||
|
||||
this.logger.log(`Created call record: ${callId} (${callStatus})${resolved.leadName ? ` linked to ${resolved.leadName}` : ''}`);
|
||||
// Step 2: For answered calls, the dispose endpoint creates the
|
||||
// Call record ~5s before this webhook fires. Check if it already
|
||||
// exists and enrich it instead of creating a duplicate.
|
||||
let callId: string;
|
||||
if (callStatus === 'COMPLETED' && ucid) {
|
||||
const existing = await this.platform.queryWithAuth<any>(
|
||||
`{ calls(first: 1, filter: { ucid: { eq: "${ucid}" } }) { edges { node { id } } } }`,
|
||||
undefined, authHeader,
|
||||
).catch(() => null);
|
||||
const existingId = existing?.calls?.edges?.[0]?.node?.id;
|
||||
if (existingId) {
|
||||
// Enrich existing record with webhook data (recording, chain name, timing)
|
||||
const enrichData: Record<string, any> = {};
|
||||
if (agentName) enrichData.agentName = agentName;
|
||||
if (recordingUrl) enrichData.recording = { primaryLinkUrl: recordingUrl, primaryLinkLabel: 'Recording' };
|
||||
if (resolved.leadId) enrichData.leadId = resolved.leadId;
|
||||
if (resolved.leadName) enrichData.leadName = resolved.leadName;
|
||||
if (startTime) enrichData.startedAt = istToUtc(startTime);
|
||||
if (endTime) enrichData.endedAt = istToUtc(endTime);
|
||||
if (duration) enrichData.durationSec = duration;
|
||||
if (Object.keys(enrichData).length > 0) {
|
||||
await this.platform.queryWithAuth<any>(
|
||||
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
||||
{ id: existingId, data: enrichData },
|
||||
authHeader,
|
||||
).catch(err => this.logger.warn(`[WEBHOOK] Failed to enrich call ${existingId}: ${err}`));
|
||||
}
|
||||
callId = existingId;
|
||||
this.logger.log(`[WEBHOOK] Enriched existing call ${callId} with recording=${recordingUrl ? 'yes' : 'no'} agentName=${agentName}`);
|
||||
} else {
|
||||
// Fallback: dispose didn't create it (edge case) — create normally
|
||||
this.logger.log(`[WEBHOOK] No existing call found for ucid=${ucid} — creating new record`);
|
||||
callId = await this.createCall({
|
||||
callerPhone, direction, callStatus, agentName,
|
||||
startTime, endTime, duration, recordingUrl, disposition, ucid,
|
||||
leadId: resolved.leadId || null, leadName: resolved.leadName,
|
||||
}, authHeader);
|
||||
this.logger.log(`Created call record: ${callId} (${callStatus})${resolved.leadName ? ` linked to ${resolved.leadName}` : ''}`);
|
||||
}
|
||||
} else {
|
||||
// Missed calls — always create (no dispose fires for unanswered)
|
||||
callId = await this.createCall({
|
||||
callerPhone, direction, callStatus, agentName,
|
||||
startTime, endTime, duration, recordingUrl, disposition, ucid,
|
||||
leadId: resolved.leadId || null, leadName: resolved.leadName,
|
||||
}, authHeader);
|
||||
this.logger.log(`Created call record: ${callId} (${callStatus})${resolved.leadName ? ` linked to ${resolved.leadName}` : ''}`);
|
||||
}
|
||||
|
||||
// Push worklist SSE so agents see new calls instantly
|
||||
// instead of waiting for the 30s frontend poll.
|
||||
|
||||
Reference in New Issue
Block a user