mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
Compare commits
16 Commits
2d8308bed8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 34eae1c19a | |||
| 68ba3e135d | |||
| e1babb30e5 | |||
| ae360a183d | |||
| e03b1e6235 | |||
| 2d18110786 | |||
| a576552f8a | |||
| b11f4ea336 | |||
| 96ae867288 | |||
| 9a016a2ed0 | |||
| 9cf0f69dde | |||
| a6f4c51ca9 | |||
|
|
1dd8413297 | ||
|
|
7d8424b446 | ||
|
|
55b8680923 | ||
|
|
973614749b |
@@ -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,16 +99,20 @@ 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.`;
|
||||
}
|
||||
}
|
||||
|
||||
const platformService = this.platform;
|
||||
@@ -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();
|
||||
|
||||
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>;
|
||||
233
src/caller/caller-context.service.ts
Normal file
233
src/caller/caller-context.service.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
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;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
const [leadData, appointmentsData, callsData, activitiesData] = await Promise.all([
|
||||
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,
|
||||
),
|
||||
patientId ? this.platform.queryWithAuth<any>(
|
||||
`{ appointments(first: 10, filter: { patientId: { eq: "${patientId}" } }, 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 lead = leadData?.lead;
|
||||
if (!lead) return null;
|
||||
|
||||
const firstName = lead.contactName?.firstName ?? '';
|
||||
const lastName = lead.contactName?.lastName ?? '';
|
||||
|
||||
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: patientId || lead.patientId || '',
|
||||
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,12 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -125,6 +125,21 @@ RULES:
|
||||
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({
|
||||
|
||||
@@ -972,4 +972,110 @@ export class MaintController {
|
||||
this.logger.log(`[MAINT] Appointment clinic backfill complete: scanned=${appointments.length} patched=${patched} skipped=${skipped} reasons=${JSON.stringify(skippedReasons)}`);
|
||||
return { status: 'ok', scanned: appointments.length, patched, skipped, skippedReasons };
|
||||
}
|
||||
|
||||
// Backfill disposition + SLA timing on historical calls using CDR data.
|
||||
// Walks calls from a given date (IST), joins to CDR by UCID, and patches
|
||||
// disposition (from CDR's mapped value) + timing fields. Idempotent —
|
||||
// only overwrites null fields (disposition is always overwritten since
|
||||
// the webhook default is unreliable).
|
||||
@Post('backfill-call-disposition-timing')
|
||||
async backfillCallDispositionTiming(@Body() body: { date?: string }) {
|
||||
const date = body.date ?? new Date(Date.now() + 5.5 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
this.logger.log(`[MAINT] Backfill disposition+timing for date=${date}`);
|
||||
|
||||
// Fetch CDR for the date
|
||||
const cdrRows = await this.ozonetel.fetchCDR({ date }).catch(() => []);
|
||||
if (cdrRows.length === 0) return { status: 'ok', date, scanned: 0, patched: 0, skipped: 0 };
|
||||
|
||||
// Build UCID + monitorUCID map
|
||||
const byUcid = new Map<string, any>();
|
||||
for (const row of cdrRows) {
|
||||
const ucid = String(row.UCID ?? '').trim();
|
||||
const monUcid = String(row.monitorUCID ?? '').trim();
|
||||
if (ucid) byUcid.set(ucid, row);
|
||||
if (monUcid && monUcid !== ucid) byUcid.set(monUcid, row);
|
||||
}
|
||||
|
||||
// Fetch calls for the date that have a UCID
|
||||
const gte = `${date}T00:00:00+05:30`;
|
||||
const lte = `${date}T23:59:59+05:30`;
|
||||
const callsData = await this.platform.query<any>(
|
||||
`{ calls(first: 500, filter: {
|
||||
startedAt: { gte: "${gte}", lte: "${lte}" },
|
||||
ucid: { is: NOT_NULL }
|
||||
}) { edges { node {
|
||||
id ucid disposition assignedAt answeredAt responseTimeS startedAt
|
||||
} } } }`,
|
||||
).catch(() => ({ calls: { edges: [] } }));
|
||||
|
||||
const calls = callsData?.calls?.edges?.map((e: any) => e.node) ?? [];
|
||||
let patched = 0;
|
||||
let skipped = 0;
|
||||
|
||||
const dispositionMap: Record<string, string> = {
|
||||
'General Enquiry': 'INFO_PROVIDED',
|
||||
'Appointment Booked': 'APPOINTMENT_BOOKED',
|
||||
'Follow Up': 'FOLLOW_UP_SCHEDULED',
|
||||
'Not Interested': 'NOT_INTERESTED',
|
||||
'Wrong Number': 'WRONG_NUMBER',
|
||||
'No Answer': 'NO_ANSWER',
|
||||
};
|
||||
|
||||
const parseHms = (hms: string | null | undefined): number | null => {
|
||||
if (!hms) return null;
|
||||
const parts = String(hms).split(':').map(Number);
|
||||
if (parts.length !== 3 || parts.some(isNaN)) return null;
|
||||
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
};
|
||||
|
||||
for (const call of calls) {
|
||||
const cdrRow = byUcid.get(String(call.ucid).trim());
|
||||
if (!cdrRow) { skipped++; continue; }
|
||||
|
||||
const patch: Record<string, any> = {};
|
||||
|
||||
// Disposition — always overwrite (webhook default is unreliable)
|
||||
const cdrDisp = dispositionMap[cdrRow.Disposition] ?? null;
|
||||
if (cdrDisp) patch.disposition = cdrDisp;
|
||||
|
||||
// Timing — only fill if null
|
||||
if (!call.answeredAt && cdrRow.AnswerTime) {
|
||||
patch.answeredAt = new Date(cdrRow.AnswerTime).toISOString();
|
||||
}
|
||||
if (!call.assignedAt && cdrRow.StartTime) {
|
||||
patch.assignedAt = new Date(cdrRow.StartTime).toISOString();
|
||||
}
|
||||
if (!call.responseTimeS && call.startedAt && (patch.answeredAt || call.answeredAt)) {
|
||||
const start = new Date(call.startedAt).getTime();
|
||||
const answered = new Date(patch.answeredAt ?? call.answeredAt).getTime();
|
||||
if (!isNaN(start) && !isNaN(answered)) {
|
||||
patch.responseTimeS = Math.max(0, Math.round((answered - start) / 1000));
|
||||
}
|
||||
}
|
||||
|
||||
// CDR timing fields
|
||||
const handlingSec = parseHms(cdrRow.HandlingTime);
|
||||
const wrapupSec = parseHms(cdrRow.WrapupDuration);
|
||||
const holdSec = parseHms(cdrRow.HoldDuration);
|
||||
if (handlingSec !== null) patch.handlingTimeS = handlingSec;
|
||||
if (wrapupSec !== null) patch.acwDurationS = wrapupSec;
|
||||
if (holdSec !== null) patch.holdDurationS = holdSec;
|
||||
|
||||
if (Object.keys(patch).length === 0) { skipped++; continue; }
|
||||
|
||||
try {
|
||||
await this.platform.query<any>(
|
||||
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
||||
{ id: call.id, data: patch },
|
||||
);
|
||||
patched++;
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`[MAINT] Backfill patch failed for ${call.id}: ${err.message}`);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`[MAINT] Disposition+timing backfill complete: date=${date} scanned=${calls.length} patched=${patched} skipped=${skipped}`);
|
||||
return { status: 'ok', date, scanned: calls.length, patched, skipped };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,6 +278,34 @@ 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-assign next missed call to this agent
|
||||
try {
|
||||
await this.missedQueue.assignNext(agentId);
|
||||
@@ -354,6 +382,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];
|
||||
@@ -52,4 +63,33 @@ export class SupervisorController {
|
||||
} as MessageEvent)),
|
||||
);
|
||||
}
|
||||
|
||||
// Worklist SSE — broadcast to all connected agents. When a missed
|
||||
// call is created by the webhook, this fires immediately so agents
|
||||
// don't wait for the 30s worklist poll. The payload includes the
|
||||
// caller's phone + name for a toast notification.
|
||||
@Sse('worklist/stream')
|
||||
streamWorklistUpdates(): Observable<MessageEvent> {
|
||||
this.logger.log('[SSE] Worklist stream opened');
|
||||
return this.supervisor.worklistSubject.pipe(
|
||||
map(event => ({
|
||||
data: JSON.stringify(event),
|
||||
} 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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,16 @@ export class SupervisorService implements OnModuleInit {
|
||||
private readonly agentStates = new Map<string, AgentStateEntry>();
|
||||
private readonly acwTimers = new Map<string, NodeJS.Timeout>();
|
||||
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.
|
||||
readonly worklistSubject = new Subject<{ type: string; callerPhone?: string; callerName?: string; callId?: string; timestamp: string }>();
|
||||
|
||||
emitWorklistUpdate(data: { type: string; callerPhone?: string; callerName?: string; callId?: string }) {
|
||||
this.worklistSubject.next({ ...data, timestamp: new Date().toISOString() });
|
||||
this.logger.log(`[WORKLIST-SSE] ${data.type} phone=${data.callerPhone ?? '?'} name=${data.callerName ?? '?'}`);
|
||||
}
|
||||
|
||||
// Barge session tracking — key is agentId
|
||||
private readonly bargeSessions = new Map<string, {
|
||||
@@ -86,10 +96,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
|
||||
@@ -101,10 +110,27 @@ export class SupervisorService implements OnModuleInit {
|
||||
eventType: 'CALL_START',
|
||||
eventAt: iso,
|
||||
}).catch(() => {});
|
||||
|
||||
// Write answeredAt + responseTimeS to the Call record.
|
||||
// Look up the Call by UCID, then patch. The "Calling" event
|
||||
// sets assignedAt (ring start); "Answered" computes response
|
||||
// time as answered - assigned (queue wait time).
|
||||
this.patchCallTimingByUcid(ucid, {
|
||||
answeredAt: iso,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// "Calling" = agent's phone is ringing → write assignedAt
|
||||
// (the moment the call was routed to this agent).
|
||||
if (action === 'Calling') {
|
||||
this.patchCallTimingByUcid(ucid, {
|
||||
assignedAt: iso,
|
||||
}).catch(() => {});
|
||||
}
|
||||
} 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.
|
||||
@@ -269,6 +295,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
|
||||
@@ -306,6 +343,50 @@ export class SupervisorService implements OnModuleInit {
|
||||
return Array.from(this.activeCalls.values());
|
||||
}
|
||||
|
||||
// Look up a Call by UCID and patch its timing fields. Used by
|
||||
// handleCallEvent to write assignedAt/answeredAt in real-time.
|
||||
// Also computes responseTimeS when answeredAt is written and
|
||||
// the Call already has a startedAt.
|
||||
private async patchCallTimingByUcid(ucid: string, fields: {
|
||||
assignedAt?: string;
|
||||
answeredAt?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const data = await this.platform.query<any>(
|
||||
`{ calls(first: 1, filter: { ucid: { eq: "${ucid}" } }) { edges { node { id startedAt assignedAt } } } }`,
|
||||
);
|
||||
const call = data?.calls?.edges?.[0]?.node;
|
||||
if (!call) {
|
||||
this.logger.warn(`[SLA] No Call for ucid=${ucid} — timing not written`);
|
||||
return;
|
||||
}
|
||||
|
||||
const patch: Record<string, any> = {};
|
||||
if (fields.assignedAt) patch.assignedAt = fields.assignedAt;
|
||||
if (fields.answeredAt) {
|
||||
patch.answeredAt = fields.answeredAt;
|
||||
// Compute response time: answered - started (how long the
|
||||
// caller waited from call creation to agent pickup).
|
||||
const start = call.startedAt ? new Date(call.startedAt).getTime() : null;
|
||||
const answered = new Date(fields.answeredAt).getTime();
|
||||
if (start && !isNaN(start) && !isNaN(answered)) {
|
||||
const responseS = Math.max(0, Math.round((answered - start) / 1000));
|
||||
patch.responseTimeS = responseS;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await this.platform.query<any>(
|
||||
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
||||
{ id: call.id, data: patch },
|
||||
);
|
||||
this.logger.log(`[SLA] Patched call ${call.id} — ${Object.entries(patch).map(([k, v]) => `${k}=${v}`).join(' ')}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`[SLA] patchCallTimingByUcid failed for ${ucid}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getTeamPerformance(date: string): Promise<any> {
|
||||
// Get all agents from platform. Field names are label-derived
|
||||
// camelCase on the current platform schema — see
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Controller, Post, Body, Headers, Logger } from '@nestjs/common';
|
||||
import { Controller, Post, Body, Headers, Logger, Inject, forwardRef } from '@nestjs/common';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { AgentLookupService } from '../platform/agent-lookup.service';
|
||||
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||
import { SupervisorService } from '../supervisor/supervisor.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
// Ozonetel sends all timestamps in IST — convert to UTC for storage
|
||||
@@ -24,6 +25,7 @@ export class MissedCallWebhookController {
|
||||
private readonly config: ConfigService,
|
||||
private readonly caller: CallerResolutionService,
|
||||
private readonly agentLookup: AgentLookupService,
|
||||
@Inject(forwardRef(() => SupervisorService)) private readonly supervisor: SupervisorService,
|
||||
) {
|
||||
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||
}
|
||||
@@ -126,6 +128,15 @@ export class MissedCallWebhookController {
|
||||
|
||||
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.
|
||||
this.supervisor.emitWorklistUpdate({
|
||||
type: callStatus === 'MISSED' ? 'missed-call' : 'inbound-call',
|
||||
callerPhone: callerPhone,
|
||||
callerName: resolved.leadName ?? undefined,
|
||||
callId,
|
||||
});
|
||||
|
||||
// Step 3: Lead-side side-effects (activity log + contact stats)
|
||||
if (resolved.leadId) {
|
||||
const summary = callStatus === 'MISSED'
|
||||
|
||||
@@ -4,6 +4,7 @@ import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { RulesEngineModule } from '../rules-engine/rules-engine.module';
|
||||
import { CallerResolutionModule } from '../caller/caller-resolution.module';
|
||||
import { SupervisorModule } from '../supervisor/supervisor.module';
|
||||
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||
import { WorklistController } from './worklist.controller';
|
||||
import { WorklistService } from './worklist.service';
|
||||
@@ -12,7 +13,7 @@ import { MissedCallWebhookController } from './missed-call-webhook.controller';
|
||||
import { KookooCallbackController } from './kookoo-callback.controller';
|
||||
|
||||
@Module({
|
||||
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule), forwardRef(() => AuthModule), RulesEngineModule, forwardRef(() => CallerResolutionModule)],
|
||||
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule), forwardRef(() => AuthModule), RulesEngineModule, forwardRef(() => CallerResolutionModule), forwardRef(() => SupervisorModule)],
|
||||
controllers: [WorklistController, MissedCallWebhookController, KookooCallbackController],
|
||||
providers: [WorklistService, MissedQueueService, TelephonyConfigService],
|
||||
exports: [MissedQueueService],
|
||||
|
||||
Reference in New Issue
Block a user