mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
Compare commits
63 Commits
dev-main
...
b11f4ea336
| Author | SHA1 | Date | |
|---|---|---|---|
| b11f4ea336 | |||
| 96ae867288 | |||
| 9a016a2ed0 | |||
| 9cf0f69dde | |||
| a6f4c51ca9 | |||
| 2d8308bed8 | |||
| 2666a10f48 | |||
| a00668c517 | |||
| a1413aae40 | |||
| 6adb3985cb | |||
| 67c41f4783 | |||
| d459d6469a | |||
| 60d2329dd8 | |||
| f375e7736c | |||
| 96977e84a1 | |||
| 00303df95b | |||
| 34e053204f | |||
| 98f5bc0347 | |||
| 048545317d | |||
| 8dcfa5a72f | |||
| 5b40f49b65 | |||
| fb616d47ee | |||
| 6fd17acf78 | |||
| 846c5f4c9b | |||
| 9472f83cd8 | |||
| 6de1989536 | |||
| 2acba59963 | |||
| 4eb8cb80b2 | |||
| fbe782b5ac | |||
| b6b597fdda | |||
| a4ff052fef | |||
| 5969441868 | |||
| 01348123e6 | |||
| d97d73dd1a | |||
| 7b178f9dc7 | |||
| 3d790e51dc | |||
| 1c3e42ad7c | |||
| ea60787da0 | |||
| c23792496b | |||
| 27a3fbcfed | |||
| 0f5bd7d61a | |||
| f1313f0e2f | |||
| 44f1ec36e1 | |||
| 4bd08a9b02 | |||
| 0248c4cad1 | |||
| be505b8d1f | |||
| dbefa9675a | |||
| 9dc02e107a | |||
| c807cf737f | |||
| 96d0c32000 | |||
| 9665500b63 | |||
| 9f5935e417 | |||
| 898ff65951 | |||
| 7717536622 | |||
| 33dc8b5669 | |||
| ab65823c2e | |||
| 695f119c2b | |||
| eacfce6970 | |||
| 619e9ab405 | |||
| e6c8d950ea | |||
| aa41a2abb7 | |||
| 517b2661b0 | |||
| 76fa6f51de |
@@ -1,3 +1,14 @@
|
|||||||
|
# Build artifacts and host-installed deps — the multi-stage Dockerfile
|
||||||
|
# rebuilds these inside the container for the target platform, so the
|
||||||
|
# host copies must NOT leak in (would clobber linux/amd64 binaries
|
||||||
|
# with darwin/arm64 ones).
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Secrets and local state
|
||||||
.env
|
.env
|
||||||
|
.env.local
|
||||||
.git
|
.git
|
||||||
src
|
|
||||||
|
# Local data dirs (Redis cache file, setup-state, etc.)
|
||||||
|
data
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -37,3 +37,8 @@ lerna-debug.log*
|
|||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# Widget config — instance-specific, auto-generated on first boot.
|
||||||
|
# Each environment mints its own HMAC-signed site key.
|
||||||
|
data/widget.json
|
||||||
|
data/widget-backups/
|
||||||
|
|||||||
24
.woodpecker.yml
Normal file
24
.woodpecker.yml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Woodpecker CI pipeline for Helix Engage Server (sidecar)
|
||||||
|
|
||||||
|
when:
|
||||||
|
- event: [push, manual]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
unit-tests:
|
||||||
|
image: node:20
|
||||||
|
commands:
|
||||||
|
- npm ci
|
||||||
|
- npm test -- --ci --forceExit
|
||||||
|
|
||||||
|
notify-teams:
|
||||||
|
image: curlimages/curl
|
||||||
|
environment:
|
||||||
|
TEAMS_WEBHOOK:
|
||||||
|
from_secret: teams_webhook
|
||||||
|
commands:
|
||||||
|
- >
|
||||||
|
curl -s -X POST "$TEAMS_WEBHOOK"
|
||||||
|
-H "Content-Type:application/json"
|
||||||
|
-d '{"type":"message","attachments":[{"contentType":"application/vnd.microsoft.card.adaptive","content":{"type":"AdaptiveCard","version":"1.4","body":[{"type":"TextBlock","size":"Medium","weight":"Bolder","text":"Helix Engage Server — Build #'"$CI_PIPELINE_NUMBER"'"},{"type":"TextBlock","text":"Branch: '"$CI_COMMIT_BRANCH"'","wrap":true},{"type":"TextBlock","text":"'"$(echo $CI_COMMIT_MESSAGE | head -c 80)"'","wrap":true}],"actions":[{"type":"Action.OpenUrl","title":"View Pipeline","url":"https://operations.healix360.net/repos/2/pipeline/'"$CI_PIPELINE_NUMBER"'"}]}}]}'
|
||||||
|
when:
|
||||||
|
- status: [success, failure]
|
||||||
57
Dockerfile
57
Dockerfile
@@ -1,7 +1,58 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
#
|
||||||
|
# Multi-stage build for the helix-engage sidecar.
|
||||||
|
#
|
||||||
|
# Why multi-stage instead of "build on host, COPY dist + node_modules"?
|
||||||
|
# The host (developer Mac, CI runner) is rarely the same architecture
|
||||||
|
# as the target (linux/amd64 EC2 / VPS). Copying a host-built
|
||||||
|
# node_modules brings darwin-arm64 native bindings (sharp, livekit,
|
||||||
|
# fsevents, etc.) into the runtime image, which crash on first import.
|
||||||
|
# This Dockerfile rebuilds inside the target-platform container so
|
||||||
|
# native bindings are downloaded/compiled for the right arch.
|
||||||
|
#
|
||||||
|
# The build stage runs `npm ci` + `nest build`, then `npm prune` to
|
||||||
|
# strip dev deps. The runtime stage carries forward only `dist/`,
|
||||||
|
# the pruned `node_modules/`, and `package.json`.
|
||||||
|
|
||||||
|
# --- Builder stage ----------------------------------------------------------
|
||||||
|
FROM node:22-slim AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Build deps for any native modules whose prebuilt binaries miss the
|
||||||
|
# target arch. Kept minimal — node:22-slim already ships most of what's
|
||||||
|
# needed for the deps in this project, but python/make/g++ are the
|
||||||
|
# canonical "I might need to gyp-rebuild" trio.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
python3 \
|
||||||
|
make \
|
||||||
|
g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Lockfile-only install first so this layer caches when only source
|
||||||
|
# changes — much faster repeat builds.
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --no-audit --no-fund --loglevel=verbose
|
||||||
|
|
||||||
|
# Source + build config
|
||||||
|
COPY tsconfig.json tsconfig.build.json nest-cli.json ./
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Strip dev dependencies so the runtime image stays small.
|
||||||
|
RUN npm prune --omit=dev
|
||||||
|
|
||||||
|
|
||||||
|
# --- Runtime stage ----------------------------------------------------------
|
||||||
FROM node:22-slim
|
FROM node:22-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY dist ./dist
|
|
||||||
COPY node_modules ./node_modules
|
# Bring across only what the runtime needs. Source, dev deps, build
|
||||||
COPY package.json ./
|
# tooling all stay in the builder stage and get discarded.
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/package.json ./
|
||||||
|
|
||||||
EXPOSE 4100
|
EXPOSE 4100
|
||||||
CMD ["node", "dist/main.js"]
|
CMD ["node", "dist/main.js"]
|
||||||
|
|||||||
3217
package-lock.json
generated
3217
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
41
public/test.html
Normal file
41
public/test.html
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Global Hospital — Widget Test</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, sans-serif; max-width: 800px; margin: 0 auto; padding: 40px; color: #1f2937; }
|
||||||
|
h1 { font-size: 28px; margin-bottom: 8px; }
|
||||||
|
p { color: #6b7280; line-height: 1.6; }
|
||||||
|
.hero { background: #f0f9ff; border-radius: 12px; padding: 40px; margin: 40px 0; }
|
||||||
|
.hero h2 { color: #1e40af; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>🏥 Global Hospital, Bangalore</h1>
|
||||||
|
<p>Welcome to Global Hospital — Bangalore's leading multi-specialty healthcare provider.</p>
|
||||||
|
|
||||||
|
<div class="hero">
|
||||||
|
<h2>Book Your Appointment Online</h2>
|
||||||
|
<p>Click the chat bubble in the bottom-right corner to talk to our AI assistant, book an appointment, or request a callback.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Our Departments</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Cardiology</li>
|
||||||
|
<li>Orthopedics</li>
|
||||||
|
<li>Gynecology</li>
|
||||||
|
<li>ENT</li>
|
||||||
|
<li>General Medicine</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p style="margin-top: 40px; font-size: 12px; color: #9ca3af;">
|
||||||
|
This is a test page for the Helix Engage website widget.
|
||||||
|
The widget loads from the sidecar and renders in a shadow DOM.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Replace SITE_KEY with the generated key -->
|
||||||
|
<script src="http://localhost:4100/widget.js" data-key="8197d39c9ad946ef.31e3b1f492a7380f77ea0c90d2f86d5d4b1ac8f70fd01423ac3d85b87d9aa511"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
463
public/widget.js
Normal file
463
public/widget.js
Normal file
File diff suppressed because one or more lines are too long
225
src/__fixtures__/ozonetel-payloads.ts
Normal file
225
src/__fixtures__/ozonetel-payloads.ts
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
/**
|
||||||
|
* Ozonetel API fixtures — accurate to the official docs (2026-04-10).
|
||||||
|
*
|
||||||
|
* These represent the EXACT shapes Ozonetel sends/returns. Used by
|
||||||
|
* unit tests to mock Ozonetel API responses and replay webhook payloads
|
||||||
|
* without a live Ozonetel account.
|
||||||
|
*
|
||||||
|
* Source: https://docs.ozonetel.com/reference
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── Webhook "URL to Push" payloads ──────────────────────────────
|
||||||
|
// Ozonetel POSTs these to our /webhooks/ozonetel/missed-call endpoint.
|
||||||
|
// Field names match the CDR detail record (PascalCase).
|
||||||
|
|
||||||
|
export const WEBHOOK_INBOUND_ANSWERED = {
|
||||||
|
CallerID: '9949879837',
|
||||||
|
Status: 'Answered',
|
||||||
|
Type: 'InBound',
|
||||||
|
StartTime: '2026-04-09 14:30:00',
|
||||||
|
EndTime: '2026-04-09 14:34:00',
|
||||||
|
CallDuration: '00:04:00',
|
||||||
|
AgentName: 'global',
|
||||||
|
AudioFile: 'https://s3.ap-southeast-1.amazonaws.com/recordings.kookoo.in/global_healthx/20260409_143000.mp3',
|
||||||
|
monitorUCID: '31712345678901234',
|
||||||
|
Disposition: 'General Enquiry',
|
||||||
|
HangupBy: 'CustomerHangup',
|
||||||
|
DID: '918041763400',
|
||||||
|
CampaignName: 'Inbound_918041763400',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WEBHOOK_INBOUND_MISSED = {
|
||||||
|
CallerID: '6309248884',
|
||||||
|
Status: 'NotAnswered',
|
||||||
|
Type: 'InBound',
|
||||||
|
StartTime: '2026-04-09 15:00:00',
|
||||||
|
EndTime: '2026-04-09 15:00:30',
|
||||||
|
CallDuration: '00:00:00',
|
||||||
|
AgentName: '',
|
||||||
|
AudioFile: '',
|
||||||
|
monitorUCID: '31712345678905678',
|
||||||
|
Disposition: '',
|
||||||
|
HangupBy: 'CustomerHangup',
|
||||||
|
DID: '918041763400',
|
||||||
|
CampaignName: 'Inbound_918041763400',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WEBHOOK_OUTBOUND_ANSWERED = {
|
||||||
|
CallerID: '',
|
||||||
|
Status: 'Answered',
|
||||||
|
Type: 'OutBound',
|
||||||
|
StartTime: '2026-04-09 16:00:00',
|
||||||
|
EndTime: '2026-04-09 16:03:00',
|
||||||
|
CallDuration: '00:03:00',
|
||||||
|
AgentName: 'global',
|
||||||
|
AudioFile: 'https://s3.ap-southeast-1.amazonaws.com/recordings.kookoo.in/global_healthx/20260409_160000.mp3',
|
||||||
|
monitorUCID: '31712345678909999',
|
||||||
|
Disposition: 'Appointment Booked',
|
||||||
|
HangupBy: 'AgentHangup',
|
||||||
|
DID: '918041763400',
|
||||||
|
CampaignName: 'Inbound_918041763400',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WEBHOOK_OUTBOUND_NO_ANSWER = {
|
||||||
|
CallerID: '',
|
||||||
|
Status: 'NotAnswered',
|
||||||
|
Type: 'OutBound',
|
||||||
|
StartTime: '2026-04-09 16:10:00',
|
||||||
|
EndTime: '2026-04-09 16:10:45',
|
||||||
|
CallDuration: '00:00:00',
|
||||||
|
AgentName: 'global',
|
||||||
|
AudioFile: '',
|
||||||
|
monitorUCID: '31712345678908888',
|
||||||
|
Disposition: '',
|
||||||
|
HangupBy: 'Timeout',
|
||||||
|
DID: '918041763400',
|
||||||
|
CampaignName: 'Inbound_918041763400',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Agent Authentication ────────────────────────────────────────
|
||||||
|
// POST /CAServices/AgentAuthenticationV2/index.php
|
||||||
|
|
||||||
|
export const AGENT_AUTH_LOGIN_SUCCESS = {
|
||||||
|
status: 'success',
|
||||||
|
message: 'Agent global logged in successfully',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AGENT_AUTH_LOGIN_ALREADY = {
|
||||||
|
status: 'error',
|
||||||
|
message: 'Agent has already logged in',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AGENT_AUTH_LOGOUT_SUCCESS = {
|
||||||
|
status: 'success',
|
||||||
|
message: 'Agent global logged out successfully',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AGENT_AUTH_INVALID = {
|
||||||
|
status: 'error',
|
||||||
|
message: 'Invalid Authentication',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Set Disposition ─────────────────────────────────────────────
|
||||||
|
// POST /ca_apis/DispositionAPIV2 (action=Set)
|
||||||
|
|
||||||
|
export const DISPOSITION_SET_DURING_CALL = {
|
||||||
|
status: 'Success',
|
||||||
|
message: 'Disposition Queued Successfully',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DISPOSITION_SET_AFTER_CALL = {
|
||||||
|
details: 'Disposition saved successfully',
|
||||||
|
status: 'Success',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DISPOSITION_SET_UPDATE = {
|
||||||
|
status: 'Success',
|
||||||
|
message: 'Disposition Updated Successfully',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DISPOSITION_INVALID_UCID = {
|
||||||
|
status: 'Fail',
|
||||||
|
message: 'Invalid ucid',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DISPOSITION_INVALID_AGENT = {
|
||||||
|
status: 'Fail',
|
||||||
|
message: 'Invalid Agent ID',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── CDR Detail Record ──────────────────────────────────────────
|
||||||
|
// GET /ca_reports/fetchCDRDetails
|
||||||
|
|
||||||
|
export const CDR_DETAIL_RECORD = {
|
||||||
|
AgentDialStatus: 'answered',
|
||||||
|
AgentID: 'global',
|
||||||
|
AgentName: 'global',
|
||||||
|
CallAudio: 'https://s3.ap-southeast-1.amazonaws.com/recordings.kookoo.in/global_healthx/20260409_143000.mp3',
|
||||||
|
CallDate: '2026-04-09',
|
||||||
|
CallID: 31733467784618213,
|
||||||
|
CallerConfAudioFile: '',
|
||||||
|
CallerID: '9949879837',
|
||||||
|
CampaignName: 'Inbound_918041763400',
|
||||||
|
Comments: '',
|
||||||
|
ConferenceDuration: '00:00:00',
|
||||||
|
CustomerDialStatus: 'answered',
|
||||||
|
CustomerRingTime: '00:00:05',
|
||||||
|
DID: '918041763400',
|
||||||
|
DialOutName: '',
|
||||||
|
DialStatus: 'answered',
|
||||||
|
DialedNumber: '523590',
|
||||||
|
Disposition: 'General Enquiry',
|
||||||
|
Duration: '00:04:00',
|
||||||
|
DynamicDID: '',
|
||||||
|
E164: '+919949879837',
|
||||||
|
EndTime: '14:34:00',
|
||||||
|
Event: 'AgentDial',
|
||||||
|
HandlingTime: '00:04:05',
|
||||||
|
HangupBy: 'CustomerHangup',
|
||||||
|
HoldDuration: '00:00:00',
|
||||||
|
Location: 'Bangalore',
|
||||||
|
PickupTime: '14:30:05',
|
||||||
|
Rating: 0,
|
||||||
|
RatingComments: '',
|
||||||
|
Skill: 'General',
|
||||||
|
StartTime: '14:30:00',
|
||||||
|
Status: 'Answered',
|
||||||
|
TalkTime: '00:04:00',
|
||||||
|
TimeToAnswer: '00:00:05',
|
||||||
|
TransferType: '',
|
||||||
|
TransferredTo: '',
|
||||||
|
Type: 'InBound',
|
||||||
|
UCID: 31712345678901234,
|
||||||
|
UUI: '',
|
||||||
|
WrapUpEndTime: '14:34:10',
|
||||||
|
WrapUpStartTime: '14:34:00',
|
||||||
|
WrapupDuration: '00:00:10',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CDR_RESPONSE_SUCCESS = {
|
||||||
|
status: 'success',
|
||||||
|
message: 'success',
|
||||||
|
details: [CDR_DETAIL_RECORD],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CDR_RESPONSE_EMPTY = {
|
||||||
|
status: 'success',
|
||||||
|
message: 'success',
|
||||||
|
details: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Abandon / Missed Calls ─────────────────────────────────────
|
||||||
|
// GET /ca_apis/abandonCalls
|
||||||
|
|
||||||
|
export const ABANDON_CALL_RECORD = {
|
||||||
|
monitorUCID: 31712345678905678,
|
||||||
|
type: 'InBound',
|
||||||
|
status: 'NotAnswered',
|
||||||
|
campaign: 'Inbound_918041763400',
|
||||||
|
callerID: '6309248884',
|
||||||
|
did: '918041763400',
|
||||||
|
skillID: '',
|
||||||
|
skill: '',
|
||||||
|
agentID: 'global',
|
||||||
|
agent: 'global',
|
||||||
|
hangupBy: 'CustomerHangup',
|
||||||
|
callTime: '2026-04-09 15:00:33',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ABANDON_RESPONSE_SUCCESS = {
|
||||||
|
status: 'success',
|
||||||
|
message: [ABANDON_CALL_RECORD],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ABANDON_RESPONSE_EMPTY = {
|
||||||
|
status: 'success',
|
||||||
|
message: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Get Disposition List ────────────────────────────────────────
|
||||||
|
// POST /ca_apis/DispositionAPIV2 (action=get)
|
||||||
|
|
||||||
|
export const DISPOSITION_LIST_SUCCESS = {
|
||||||
|
status: 'Success',
|
||||||
|
details: 'General Enquiry, Appointment Booked, Follow Up, Not Interested, Wrong Number, ',
|
||||||
|
};
|
||||||
@@ -5,7 +5,10 @@ import { generateText, streamText, tool, stepCountIs } from 'ai';
|
|||||||
import type { LanguageModel } from 'ai';
|
import type { LanguageModel } from 'ai';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||||
import { createAiModel, isAiConfigured } from './ai-provider';
|
import { createAiModel, isAiConfigured } from './ai-provider';
|
||||||
|
import { AiConfigService } from '../config/ai-config.service';
|
||||||
|
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../shared/doctor-utils';
|
||||||
|
|
||||||
type ChatRequest = {
|
type ChatRequest = {
|
||||||
message: string;
|
message: string;
|
||||||
@@ -23,14 +26,20 @@ export class AiChatController {
|
|||||||
constructor(
|
constructor(
|
||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
private platform: PlatformGraphqlService,
|
private platform: PlatformGraphqlService,
|
||||||
|
private aiConfig: AiConfigService,
|
||||||
|
private caller: CallerResolutionService,
|
||||||
) {
|
) {
|
||||||
this.aiModel = createAiModel(config);
|
const cfg = aiConfig.getConfig();
|
||||||
|
this.aiModel = createAiModel({
|
||||||
|
provider: cfg.provider,
|
||||||
|
model: cfg.model,
|
||||||
|
anthropicApiKey: config.get<string>('ai.anthropicApiKey'),
|
||||||
|
openaiApiKey: config.get<string>('ai.openaiApiKey'),
|
||||||
|
});
|
||||||
if (!this.aiModel) {
|
if (!this.aiModel) {
|
||||||
this.logger.warn('AI not configured — chat uses fallback');
|
this.logger.warn('AI not configured — chat uses fallback');
|
||||||
} else {
|
} else {
|
||||||
const provider = config.get<string>('ai.provider') ?? 'openai';
|
this.logger.log(`AI configured: ${cfg.provider}/${cfg.model}`);
|
||||||
const model = config.get<string>('ai.model') ?? 'gpt-4o-mini';
|
|
||||||
this.logger.log(`AI configured: ${provider}/${model}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +129,13 @@ export class AiChatController {
|
|||||||
undefined, auth,
|
undefined, auth,
|
||||||
),
|
),
|
||||||
platformService.queryWithAuth<any>(
|
platformService.queryWithAuth<any>(
|
||||||
`{ agents(first: 20) { edges { node { id name ozonetelagentid npsscore maxidleminutes minnpsthreshold minconversionpercent } } } }`,
|
// Field names are label-derived camelCase on the
|
||||||
|
// current platform schema. The legacy lowercase
|
||||||
|
// names (ozonetelagentid etc.) only still exist on
|
||||||
|
// staging workspaces that were synced from an
|
||||||
|
// older SDK. See agent-config.service.ts for the
|
||||||
|
// canonical explanation.
|
||||||
|
`{ agents(first: 20) { edges { node { id name ozonetelAgentId npsScore maxIdleMinutes minNpsThreshold minConversion } } } }`,
|
||||||
undefined, auth,
|
undefined, auth,
|
||||||
),
|
),
|
||||||
platformService.queryWithAuth<any>(
|
platformService.queryWithAuth<any>(
|
||||||
@@ -137,7 +152,7 @@ export class AiChatController {
|
|||||||
const agentMetrics = agents
|
const agentMetrics = agents
|
||||||
.filter((a: any) => !agentName || a.name.toLowerCase().includes(agentName.toLowerCase()))
|
.filter((a: any) => !agentName || a.name.toLowerCase().includes(agentName.toLowerCase()))
|
||||||
.map((agent: any) => {
|
.map((agent: any) => {
|
||||||
const agentCalls = calls.filter((c: any) => c.agentName === agent.name || c.agentName === agent.ozonetelagentid);
|
const agentCalls = calls.filter((c: any) => c.agentName === agent.name || c.agentName === agent.ozonetelAgentId);
|
||||||
const totalCalls = agentCalls.length;
|
const totalCalls = agentCalls.length;
|
||||||
const missed = agentCalls.filter((c: any) => c.callStatus === 'MISSED').length;
|
const missed = agentCalls.filter((c: any) => c.callStatus === 'MISSED').length;
|
||||||
const completed = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
|
const completed = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
|
||||||
@@ -156,12 +171,12 @@ export class AiChatController {
|
|||||||
conversionRate: `${conversionRate}%`,
|
conversionRate: `${conversionRate}%`,
|
||||||
assignedLeads: agentLeads.length,
|
assignedLeads: agentLeads.length,
|
||||||
pendingFollowUps,
|
pendingFollowUps,
|
||||||
npsScore: agent.npsscore,
|
npsScore: agent.npsScore,
|
||||||
maxIdleMinutes: agent.maxidleminutes,
|
maxIdleMinutes: agent.maxIdleMinutes,
|
||||||
minNpsThreshold: agent.minnpsthreshold,
|
minNpsThreshold: agent.minNpsThreshold,
|
||||||
minConversionPercent: agent.minconversionpercent,
|
minConversionPercent: agent.minConversion,
|
||||||
belowNpsThreshold: agent.minnpsthreshold && (agent.npsscore ?? 100) < agent.minnpsthreshold,
|
belowNpsThreshold: agent.minNpsThreshold && (agent.npsScore ?? 100) < agent.minNpsThreshold,
|
||||||
belowConversionThreshold: agent.minconversionpercent && conversionRate < agent.minconversionpercent,
|
belowConversionThreshold: agent.minConversion && conversionRate < agent.minConversion,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -258,7 +273,7 @@ export class AiChatController {
|
|||||||
inputSchema: z.object({}),
|
inputSchema: z.object({}),
|
||||||
execute: async () => {
|
execute: async () => {
|
||||||
const data = await platformService.queryWithAuth<any>(
|
const data = await platformService.queryWithAuth<any>(
|
||||||
`{ calls(first: 100, filter: { callStatus: { eq: MISSED }, callbackstatus: { eq: PENDING_CALLBACK } }) { edges { node { id callerNumber { primaryPhoneNumber } startedAt agentName sla } } } }`,
|
`{ calls(first: 100, filter: { callStatus: { eq: MISSED }, callbackStatus: { eq: PENDING_CALLBACK } }) { edges { node { id callerNumber { primaryPhoneNumber } startedAt agentName sla } } } }`,
|
||||||
undefined, auth,
|
undefined, auth,
|
||||||
);
|
);
|
||||||
const breached = data.calls.edges
|
const breached = data.calls.edges
|
||||||
@@ -280,6 +295,54 @@ export class AiChatController {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Agent tools — patient lookup, appointments, doctors
|
// Agent tools — patient lookup, appointments, doctors
|
||||||
|
//
|
||||||
|
// UUID safety: LLMs hallucinate 36-char identifiers once the context
|
||||||
|
// starts wearing thin (dropped hyphens, swapped chars). To keep the
|
||||||
|
// model off the UUID path for "this caller" questions, the tools
|
||||||
|
// below accept their id arguments OPTIONALLY — when omitted we fall
|
||||||
|
// back to the leadId carried on the call context, and resolve
|
||||||
|
// patientId from it server-side. The model is instructed (see
|
||||||
|
// ccAgentHelper prompt) to omit the id entirely when asking about
|
||||||
|
// the current caller, so it never has to echo the UUID back.
|
||||||
|
//
|
||||||
|
// Every tool below logs a one-line structured trace via `toolLog`:
|
||||||
|
// [AI-TOOL] <name> args=<...> resolved=<...> result=<...>
|
||||||
|
// This lets us see which tool the model chose, whether it passed
|
||||||
|
// the UUID through or used the context fallback, and what came
|
||||||
|
// back. Tail sidecar logs while testing and you'll see the full
|
||||||
|
// orchestration trail for each chat turn.
|
||||||
|
const logger = this.logger;
|
||||||
|
const toolLog = (name: string, args: Record<string, unknown>, outcome: Record<string, unknown>) => {
|
||||||
|
// Print full values — UUIDs in particular are kept intact so we
|
||||||
|
// can diff the model's argument against the platform record when
|
||||||
|
// hunting hallucinated ids. Grep with `AI-TOOL` to pull the
|
||||||
|
// orchestration trail for a given chat turn.
|
||||||
|
const argStr = Object.entries(args).map(([k, v]) => `${k}=${v ?? '∅'}`).join(' ');
|
||||||
|
const outStr = Object.entries(outcome).map(([k, v]) => `${k}=${v ?? '∅'}`).join(' ');
|
||||||
|
logger.log(`[AI-TOOL] ${name} ${argStr} → ${outStr}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
let cachedPatientId: string | undefined;
|
||||||
|
const resolveLeadId = (arg?: string): string | undefined => arg || ctx?.leadId || undefined;
|
||||||
|
const resolvePatientId = async (arg?: string): Promise<string | undefined> => {
|
||||||
|
if (arg) return arg;
|
||||||
|
if (cachedPatientId) return cachedPatientId;
|
||||||
|
const lid = ctx?.leadId;
|
||||||
|
if (!lid) return undefined;
|
||||||
|
try {
|
||||||
|
const data = await platformService.queryWithAuth<any>(
|
||||||
|
`{ lead(filter: { id: { eq: "${lid}" } }) { id patientId } }`,
|
||||||
|
undefined, auth,
|
||||||
|
);
|
||||||
|
cachedPatientId = data?.lead?.patientId ?? undefined;
|
||||||
|
logger.log(`[AI-TOOL] resolvePatientId lead=${lid} patientId=${cachedPatientId ?? '∅'}`);
|
||||||
|
return cachedPatientId;
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.warn(`[AI-TOOL] resolvePatientId failed: ${err?.message ?? err}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const agentTools = {
|
const agentTools = {
|
||||||
lookup_patient: tool({
|
lookup_patient: tool({
|
||||||
description: 'Search for a patient/lead by phone number or name. Returns their profile, lead status, AI summary.',
|
description: 'Search for a patient/lead by phone number or name. Returns their profile, lead status, AI summary.',
|
||||||
@@ -314,24 +377,32 @@ export class AiChatController {
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
toolLog('lookup_patient', { phone, name }, { scanned: leads.length, matched: matched.length });
|
||||||
if (!matched.length) return { found: false, message: 'No patient/lead found.' };
|
if (!matched.length) return { found: false, message: 'No patient/lead found.' };
|
||||||
return { found: true, count: matched.length, leads: matched };
|
return { found: true, count: matched.length, leads: matched };
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
lookup_appointments: tool({
|
lookup_appointments: tool({
|
||||||
description: 'Get appointments for a patient. Returns doctor, department, date, status.',
|
description: 'Get appointments for a patient. Omit patientId to use the current caller — do NOT re-type a UUID you saw in context; just call with no arguments.',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
patientId: z.string().describe('Patient ID'),
|
patientId: z.string().optional().describe('Patient ID (omit when asking about the current caller)'),
|
||||||
}),
|
}),
|
||||||
execute: async ({ patientId }) => {
|
execute: async ({ patientId }) => {
|
||||||
|
const resolved = await resolvePatientId(patientId);
|
||||||
|
if (!resolved) {
|
||||||
|
toolLog('lookup_appointments', { patientId }, { resolved: null, result: 'no-context' });
|
||||||
|
return { appointments: [], message: 'No patient context — ask the agent which patient.' };
|
||||||
|
}
|
||||||
const data = await platformService.queryWithAuth<any>(
|
const data = await platformService.queryWithAuth<any>(
|
||||||
`{ appointments(first: 20, filter: { patientId: { eq: "${patientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
`{ appointments(first: 20, filter: { patientId: { eq: "${resolved}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||||
id scheduledAt status doctorName department reasonForVisit
|
id scheduledAt status doctorName department reasonForVisit
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, auth,
|
undefined, auth,
|
||||||
);
|
);
|
||||||
return { appointments: data.appointments.edges.map((e: any) => e.node) };
|
const appointments = data.appointments.edges.map((e: any) => e.node);
|
||||||
|
toolLog('lookup_appointments', { patientId }, { resolved, count: appointments.length });
|
||||||
|
return { appointments };
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -344,13 +415,13 @@ export class AiChatController {
|
|||||||
const data = await platformService.queryWithAuth<any>(
|
const data = await platformService.queryWithAuth<any>(
|
||||||
`{ doctors(first: 10) { edges { node {
|
`{ doctors(first: 10) { edges { node {
|
||||||
id fullName { firstName lastName }
|
id fullName { firstName lastName }
|
||||||
department specialty visitingHours
|
department specialty
|
||||||
consultationFeeNew { amountMicros currencyCode }
|
consultationFeeNew { amountMicros currencyCode }
|
||||||
clinic { clinicName }
|
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, auth,
|
undefined, auth,
|
||||||
);
|
);
|
||||||
const doctors = data.doctors.edges.map((e: any) => e.node);
|
const doctors = normalizeDoctors(data.doctors.edges.map((e: any) => e.node));
|
||||||
// Strip "Dr." prefix and search flexibly
|
// Strip "Dr." prefix and search flexibly
|
||||||
const search = doctorName.toLowerCase().replace(/^dr\.?\s*/i, '').trim();
|
const search = doctorName.toLowerCase().replace(/^dr\.?\s*/i, '').trim();
|
||||||
const searchWords = search.split(/\s+/);
|
const searchWords = search.split(/\s+/);
|
||||||
@@ -360,24 +431,25 @@ export class AiChatController {
|
|||||||
const full = `${fn} ${ln}`;
|
const full = `${fn} ${ln}`;
|
||||||
return searchWords.some(w => w.length > 1 && (fn.includes(w) || ln.includes(w) || full.includes(w)));
|
return searchWords.some(w => w.length > 1 && (fn.includes(w) || ln.includes(w) || full.includes(w)));
|
||||||
});
|
});
|
||||||
this.logger.log(`[TOOL] lookup_doctor: search="${doctorName}" → ${matched.length} results`);
|
toolLog('lookup_doctor', { doctorName }, { scanned: doctors.length, matched: matched.length });
|
||||||
if (!matched.length) return { found: false, message: `No doctor matching "${doctorName}". Available: ${doctors.map((d: any) => `Dr. ${d.fullName?.lastName ?? d.fullName?.firstName}`).join(', ')}` };
|
if (!matched.length) return { found: false, message: `No doctor matching "${doctorName}". Available: ${doctors.map((d: any) => `Dr. ${d.fullName?.lastName ?? d.fullName?.firstName}`).join(', ')}` };
|
||||||
return { found: true, doctors: matched };
|
return { found: true, doctors: matched };
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
book_appointment: tool({
|
book_appointment: tool({
|
||||||
description: 'Book an appointment for a patient. Collect patient name, phone, department, doctor, preferred date/time, and reason before calling this.',
|
description: 'Book an appointment for a patient. Collect patient name, phone, department, doctor, clinic/branch, preferred date/time, and reason before calling this.',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
patientName: z.string().describe('Full name of the patient'),
|
patientName: z.string().describe('Full name of the patient'),
|
||||||
phoneNumber: z.string().describe('Patient phone number'),
|
phoneNumber: z.string().describe('Patient phone number'),
|
||||||
department: z.string().describe('Department for the appointment'),
|
department: z.string().describe('Department for the appointment'),
|
||||||
doctorName: z.string().describe('Doctor name'),
|
doctorName: z.string().describe('Doctor name'),
|
||||||
|
clinicId: z.string().optional().describe('Clinic/branch ID — get from lookup_doctor results'),
|
||||||
scheduledAt: z.string().describe('Date and time in ISO format (e.g. 2026-04-01T10:00:00)'),
|
scheduledAt: z.string().describe('Date and time in ISO format (e.g. 2026-04-01T10:00:00)'),
|
||||||
reason: z.string().describe('Reason for visit'),
|
reason: z.string().describe('Reason for visit'),
|
||||||
}),
|
}),
|
||||||
execute: async ({ patientName, phoneNumber, department, doctorName, scheduledAt, reason }) => {
|
execute: async ({ patientName, phoneNumber, department, doctorName, clinicId, scheduledAt, reason }) => {
|
||||||
this.logger.log(`[TOOL] book_appointment: ${patientName} | ${phoneNumber} | ${department} | ${doctorName} | ${scheduledAt}`);
|
toolLog('book_appointment', { patientName, phoneNumber, department, doctorName, clinicId, scheduledAt }, { reason: reason?.slice(0, 40) });
|
||||||
try {
|
try {
|
||||||
const result = await platformService.queryWithAuth<any>(
|
const result = await platformService.queryWithAuth<any>(
|
||||||
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
|
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
|
||||||
@@ -389,17 +461,20 @@ export class AiChatController {
|
|||||||
doctorName,
|
doctorName,
|
||||||
department,
|
department,
|
||||||
reasonForVisit: reason,
|
reasonForVisit: reason,
|
||||||
|
...(clinicId ? { clinicId } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
auth,
|
auth,
|
||||||
);
|
);
|
||||||
const id = result?.createAppointment?.id;
|
const id = result?.createAppointment?.id;
|
||||||
if (id) {
|
if (id) {
|
||||||
|
toolLog('book_appointment', { doctorName }, { booked: true, appointmentId: id });
|
||||||
return { booked: true, appointmentId: id, message: `Appointment booked for ${patientName} with ${doctorName} on ${scheduledAt}. Reference: ${id.substring(0, 8)}` };
|
return { booked: true, appointmentId: id, message: `Appointment booked for ${patientName} with ${doctorName} on ${scheduledAt}. Reference: ${id.substring(0, 8)}` };
|
||||||
}
|
}
|
||||||
|
toolLog('book_appointment', { doctorName }, { booked: false });
|
||||||
return { booked: false, message: 'Appointment creation failed.' };
|
return { booked: false, message: 'Appointment creation failed.' };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.error(`[TOOL] book_appointment failed: ${err.message}`);
|
logger.error(`[AI-TOOL] book_appointment failed: ${err.message}`);
|
||||||
return { booked: false, message: `Failed to book: ${err.message}` };
|
return { booked: false, message: `Failed to book: ${err.message}` };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -413,51 +488,131 @@ export class AiChatController {
|
|||||||
interest: z.string().describe('What they are enquiring about'),
|
interest: z.string().describe('What they are enquiring about'),
|
||||||
}),
|
}),
|
||||||
execute: async ({ name, phoneNumber, interest }) => {
|
execute: async ({ name, phoneNumber, interest }) => {
|
||||||
this.logger.log(`[TOOL] create_lead: ${name} | ${phoneNumber} | ${interest}`);
|
toolLog('create_lead', { name, phoneNumber, interest: interest?.slice(0, 40) }, {});
|
||||||
try {
|
try {
|
||||||
const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10);
|
const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10);
|
||||||
const result = await platformService.queryWithAuth<any>(
|
const resolved = await this.caller.resolve(cleanPhone, auth);
|
||||||
|
const firstName = name.split(' ')[0];
|
||||||
|
const lastName = name.split(' ').slice(1).join(' ') || '';
|
||||||
|
|
||||||
|
if (resolved.isNew) {
|
||||||
|
// Net-new caller — create Patient + Lead with
|
||||||
|
// the AI-collected name from the conversation.
|
||||||
|
let patientId: string | undefined;
|
||||||
|
try {
|
||||||
|
const p = await platformService.queryWithAuth<any>(
|
||||||
|
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
fullName: { firstName, lastName },
|
||||||
|
phones: { primaryPhoneNumber: `+91${cleanPhone}` },
|
||||||
|
patientType: 'NEW',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auth,
|
||||||
|
);
|
||||||
|
patientId = p?.createPatient?.id;
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.warn(`[AI-TOOL] create_lead patient create failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
const created = await platformService.queryWithAuth<any>(
|
||||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||||
{
|
{
|
||||||
data: {
|
data: {
|
||||||
name: `AI Enquiry — ${name}`,
|
name: `AI Enquiry — ${name}`,
|
||||||
contactName: {
|
contactName: { firstName, lastName },
|
||||||
firstName: name.split(' ')[0],
|
|
||||||
lastName: name.split(' ').slice(1).join(' ') || '',
|
|
||||||
},
|
|
||||||
contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` },
|
contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` },
|
||||||
source: 'PHONE',
|
source: 'PHONE',
|
||||||
status: 'NEW',
|
status: 'NEW',
|
||||||
interestedService: interest,
|
interestedService: interest,
|
||||||
|
...(patientId ? { patientId } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auth,
|
||||||
|
);
|
||||||
|
const id = created?.createLead?.id;
|
||||||
|
if (id) {
|
||||||
|
toolLog('create_lead', { name }, { created: true, isNew: true, leadId: id });
|
||||||
|
return { created: true, leadId: id, message: `Lead created for ${name}. Our team will follow up on ${phoneNumber}.` };
|
||||||
|
}
|
||||||
|
toolLog('create_lead', { name }, { created: false });
|
||||||
|
return { created: false, message: 'Lead creation failed.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing record — update with AI-collected name.
|
||||||
|
await platformService.queryWithAuth<any>(
|
||||||
|
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
id: resolved.leadId,
|
||||||
|
data: {
|
||||||
|
name: `AI Enquiry — ${name}`,
|
||||||
|
contactName: { firstName, lastName },
|
||||||
|
source: 'PHONE',
|
||||||
|
status: 'NEW',
|
||||||
|
interestedService: interest,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
auth,
|
auth,
|
||||||
);
|
);
|
||||||
const id = result?.createLead?.id;
|
if (resolved.patientId) {
|
||||||
if (id) {
|
await platformService.queryWithAuth<any>(
|
||||||
return { created: true, leadId: id, message: `Lead created for ${name}. Our team will follow up on ${phoneNumber}.` };
|
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: resolved.patientId, data: { fullName: { firstName, lastName } } },
|
||||||
|
auth,
|
||||||
|
).catch(() => {});
|
||||||
}
|
}
|
||||||
return { created: false, message: 'Lead creation failed.' };
|
toolLog('create_lead', { name }, { created: true, isNew: false, leadId: resolved.leadId });
|
||||||
|
return { created: true, leadId: resolved.leadId, message: `Lead updated for ${name}. Our team will follow up on ${phoneNumber}.` };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.error(`[TOOL] create_lead failed: ${err.message}`);
|
logger.error(`[AI-TOOL] create_lead failed: ${err.message}`);
|
||||||
return { created: false, message: `Failed: ${err.message}` };
|
return { created: false, message: `Failed: ${err.message}` };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
lookup_call_history: tool({
|
lookup_call_history: tool({
|
||||||
description: 'Get call log for a lead — all inbound/outbound calls with dispositions and durations.',
|
description: 'Get call log for a lead — all inbound/outbound calls with dispositions and durations. Omit leadId to use the current caller — do NOT re-type a UUID you saw in context; just call with no arguments.',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
leadId: z.string().describe('Lead ID'),
|
leadId: z.string().optional().describe('Lead ID (omit when asking about the current caller)'),
|
||||||
}),
|
}),
|
||||||
execute: async ({ leadId }) => {
|
execute: async ({ leadId }) => {
|
||||||
|
const resolved = resolveLeadId(leadId);
|
||||||
|
if (!resolved) {
|
||||||
|
toolLog('lookup_call_history', { leadId }, { resolved: null, result: 'no-context' });
|
||||||
|
return { calls: [], message: 'No lead context — ask the agent which caller.' };
|
||||||
|
}
|
||||||
const data = await platformService.queryWithAuth<any>(
|
const data = await platformService.queryWithAuth<any>(
|
||||||
`{ calls(first: 20, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
`{ calls(first: 20, filter: { leadId: { eq: "${resolved}" } }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
||||||
id direction callStatus agentName startedAt durationSec disposition
|
id direction callStatus agentName startedAt durationSec disposition
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, auth,
|
undefined, auth,
|
||||||
);
|
);
|
||||||
return { calls: data.calls.edges.map((e: any) => e.node) };
|
const calls = data.calls.edges.map((e: any) => e.node);
|
||||||
|
toolLog('lookup_call_history', { leadId }, { resolved, count: calls.length });
|
||||||
|
return { calls };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
lookup_lead_activities: tool({
|
||||||
|
description: 'Get activity log entries for a lead — notes, status changes, enquiries. Omit leadId to use the current caller — do NOT re-type a UUID you saw in context.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
leadId: z.string().optional().describe('Lead ID (omit when asking about the current caller)'),
|
||||||
|
}),
|
||||||
|
execute: async ({ leadId }) => {
|
||||||
|
const resolved = resolveLeadId(leadId);
|
||||||
|
if (!resolved) {
|
||||||
|
toolLog('lookup_lead_activities', { leadId }, { resolved: null, result: 'no-context' });
|
||||||
|
return { activities: [], message: 'No lead context — ask the agent which caller.' };
|
||||||
|
}
|
||||||
|
const data = await platformService.queryWithAuth<any>(
|
||||||
|
`{ leadActivities(first: 20, filter: { leadId: { eq: "${resolved}" } }, orderBy: [{ occurredAt: DescNullsLast }]) { edges { node {
|
||||||
|
id activityType summary occurredAt performedBy channel outcome
|
||||||
|
} } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
);
|
||||||
|
const activities = data.leadActivities.edges.map((e: any) => e.node);
|
||||||
|
toolLog('lookup_lead_activities', { leadId }, { resolved, count: activities.length });
|
||||||
|
return { activities };
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -503,16 +658,23 @@ export class AiChatController {
|
|||||||
`{ clinics(first: 20) { edges { node {
|
`{ clinics(first: 20) { edges { node {
|
||||||
id name clinicName
|
id name clinicName
|
||||||
addressCustom { addressStreet1 addressCity addressState addressPostcode }
|
addressCustom { addressStreet1 addressCity addressState addressPostcode }
|
||||||
weekdayHours saturdayHours sundayHours
|
openMonday openTuesday openWednesday openThursday openFriday openSaturday openSunday
|
||||||
|
opensAt closesAt
|
||||||
status walkInAllowed onlineBooking
|
status walkInAllowed onlineBooking
|
||||||
cancellationWindowHours arriveEarlyMin requiredDocuments
|
cancellationWindowHours arriveEarlyMin
|
||||||
acceptsCash acceptsCard acceptsUpi
|
acceptsCash acceptsCard acceptsUpi
|
||||||
|
requiredDocuments { edges { node { documentType notes } } }
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, auth,
|
undefined, auth,
|
||||||
);
|
);
|
||||||
const clinics = clinicData.clinics.edges.map((e: any) => e.node);
|
const clinics = clinicData.clinics.edges.map((e: any) => e.node);
|
||||||
if (clinics.length) {
|
if (clinics.length) {
|
||||||
sections.push('## CLINICS & TIMINGS');
|
sections.push('## CLINICS & TIMINGS');
|
||||||
|
const dayFlags: Array<[string, string]> = [
|
||||||
|
['Mon', 'openMonday'], ['Tue', 'openTuesday'], ['Wed', 'openWednesday'],
|
||||||
|
['Thu', 'openThursday'], ['Fri', 'openFriday'],
|
||||||
|
['Sat', 'openSaturday'], ['Sun', 'openSunday'],
|
||||||
|
];
|
||||||
for (const c of clinics) {
|
for (const c of clinics) {
|
||||||
const name = c.clinicName ?? c.name;
|
const name = c.clinicName ?? c.name;
|
||||||
const addr = c.addressCustom
|
const addr = c.addressCustom
|
||||||
@@ -520,9 +682,15 @@ export class AiChatController {
|
|||||||
: '';
|
: '';
|
||||||
sections.push(`### ${name}`);
|
sections.push(`### ${name}`);
|
||||||
if (addr) sections.push(` Address: ${addr}`);
|
if (addr) sections.push(` Address: ${addr}`);
|
||||||
if (c.weekdayHours) sections.push(` Mon–Fri: ${c.weekdayHours}`);
|
const openDays = dayFlags.filter(([, flag]) => c[flag]).map(([label]) => label);
|
||||||
if (c.saturdayHours) sections.push(` Saturday: ${c.saturdayHours}`);
|
if (openDays.length) {
|
||||||
sections.push(` Sunday: ${c.sundayHours ?? 'Closed'}`);
|
const hours = c.opensAt && c.closesAt ? ` ${c.opensAt}–${c.closesAt}` : '';
|
||||||
|
sections.push(` Open: ${openDays.join(', ')}${hours}`);
|
||||||
|
}
|
||||||
|
const closedDays = dayFlags.filter(([, flag]) => !c[flag]).map(([label]) => label);
|
||||||
|
if (closedDays.length) {
|
||||||
|
sections.push(` Closed: ${closedDays.join(', ')}`);
|
||||||
|
}
|
||||||
if (c.walkInAllowed) sections.push(` Walk-ins: Accepted`);
|
if (c.walkInAllowed) sections.push(` Walk-ins: Accepted`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,7 +698,8 @@ export class AiChatController {
|
|||||||
const rules: string[] = [];
|
const rules: string[] = [];
|
||||||
if (rulesClinic.cancellationWindowHours) rules.push(`Free cancellation up to ${rulesClinic.cancellationWindowHours}h before`);
|
if (rulesClinic.cancellationWindowHours) rules.push(`Free cancellation up to ${rulesClinic.cancellationWindowHours}h before`);
|
||||||
if (rulesClinic.arriveEarlyMin) rules.push(`Arrive ${rulesClinic.arriveEarlyMin}min early`);
|
if (rulesClinic.arriveEarlyMin) rules.push(`Arrive ${rulesClinic.arriveEarlyMin}min early`);
|
||||||
if (rulesClinic.requiredDocuments) rules.push(`First-time patients bring ${rulesClinic.requiredDocuments}`);
|
const docs = rulesClinic.requiredDocuments?.edges?.map((e: any) => e.node?.documentType).filter(Boolean) ?? [];
|
||||||
|
if (docs.length) rules.push(`First-time patients bring: ${docs.join(', ')}`);
|
||||||
if (rulesClinic.walkInAllowed) rules.push('Walk-ins accepted');
|
if (rulesClinic.walkInAllowed) rules.push('Walk-ins accepted');
|
||||||
if (rulesClinic.onlineBooking) rules.push('Online booking available');
|
if (rulesClinic.onlineBooking) rules.push('Online booking available');
|
||||||
if (rules.length) {
|
if (rules.length) {
|
||||||
@@ -556,25 +725,28 @@ export class AiChatController {
|
|||||||
try {
|
try {
|
||||||
const docData = await this.platform.queryWithAuth<any>(
|
const docData = await this.platform.queryWithAuth<any>(
|
||||||
`{ doctors(first: 20) { edges { node {
|
`{ doctors(first: 20) { edges { node {
|
||||||
fullName { firstName lastName } department specialty visitingHours
|
id fullName { firstName lastName } department specialty
|
||||||
consultationFeeNew { amountMicros currencyCode }
|
consultationFeeNew { amountMicros currencyCode }
|
||||||
clinic { clinicName }
|
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, auth,
|
undefined, auth,
|
||||||
);
|
);
|
||||||
const doctors = docData.doctors.edges.map((e: any) => e.node);
|
const doctors = normalizeDoctors(docData.doctors.edges.map((e: any) => e.node));
|
||||||
if (doctors.length) {
|
if (doctors.length) {
|
||||||
sections.push('\n## DOCTORS');
|
sections.push('\n## DOCTORS');
|
||||||
for (const d of doctors) {
|
for (const d of doctors) {
|
||||||
const name = `Dr. ${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''}`.trim();
|
const name = `Dr. ${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''}`.trim();
|
||||||
const fee = d.consultationFeeNew ? `₹${d.consultationFeeNew.amountMicros / 1_000_000}` : '';
|
const fee = d.consultationFeeNew ? `₹${d.consultationFeeNew.amountMicros / 1_000_000}` : '';
|
||||||
const clinic = d.clinic?.clinicName ?? '';
|
// List ALL clinics this doctor visits in the KB so
|
||||||
|
// the AI can answer questions like "where can I see
|
||||||
|
// Dr. X" without needing a follow-up tool call.
|
||||||
|
const clinics = d.clinics.map((c) => c.clinicName).join(', ');
|
||||||
sections.push(`### ${name}`);
|
sections.push(`### ${name}`);
|
||||||
sections.push(` Department: ${d.department ?? 'N/A'}`);
|
sections.push(` Department: ${d.department ?? 'N/A'}`);
|
||||||
sections.push(` Specialty: ${d.specialty ?? 'N/A'}`);
|
sections.push(` Specialty: ${d.specialty ?? 'N/A'}`);
|
||||||
if (d.visitingHours) sections.push(` Hours: ${d.visitingHours}`);
|
if (d.visitingHours) sections.push(` Hours: ${d.visitingHours}`);
|
||||||
if (fee) sections.push(` Consultation fee: ${fee}`);
|
if (fee) sections.push(` Consultation fee: ${fee}`);
|
||||||
if (clinic) sections.push(` Clinic: ${clinic}`);
|
if (clinics) sections.push(` Clinics: ${clinics}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -645,24 +817,15 @@ export class AiChatController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildSupervisorSystemPrompt(): string {
|
private buildSupervisorSystemPrompt(): string {
|
||||||
return `You are an AI assistant for supervisors at Global Hospital's call center (Helix Engage).
|
return this.aiConfig.renderPrompt('supervisorChat', {
|
||||||
You help supervisors monitor team performance, identify issues, and make data-driven decisions.
|
hospitalName: this.getHospitalName(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
## YOUR CAPABILITIES
|
// Best-effort hospital name lookup for the AI prompts. Falls back
|
||||||
You have access to tools that query real-time data:
|
// to a generic label so prompt rendering never throws.
|
||||||
- **Agent performance**: call counts, conversion rates, NPS scores, idle time, pending follow-ups
|
private getHospitalName(): string {
|
||||||
- **Campaign stats**: lead counts, conversion rates per campaign, platform breakdown
|
return process.env.HOSPITAL_NAME ?? 'the hospital';
|
||||||
- **Call summary**: total calls, inbound/outbound split, missed call rate, disposition breakdown
|
|
||||||
- **SLA breaches**: missed calls that haven't been called back within the SLA threshold
|
|
||||||
|
|
||||||
## RULES
|
|
||||||
1. ALWAYS use tools to fetch data before answering. NEVER guess or fabricate performance numbers.
|
|
||||||
2. Be specific — include actual numbers from the tool response, not vague qualifiers.
|
|
||||||
3. When comparing agents, use their configured thresholds (minConversionPercent, minNpsThreshold, maxIdleMinutes) and team averages. Let the data determine who is underperforming — do not assume.
|
|
||||||
4. Be concise — supervisors want quick answers. Use bullet points.
|
|
||||||
5. When recommending actions, ground them in the data returned by tools.
|
|
||||||
6. If asked about trends, use the call summary tool with different periods.
|
|
||||||
7. Do not use any agent name in a negative context unless the data explicitly supports it.`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildRulesSystemPrompt(currentConfig: any): string {
|
private buildRulesSystemPrompt(currentConfig: any): string {
|
||||||
@@ -712,25 +875,10 @@ ${configJson}
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildSystemPrompt(kb: string): string {
|
private buildSystemPrompt(kb: string): string {
|
||||||
return `You are an AI assistant for call center agents at Global Hospital, Bangalore.
|
return this.aiConfig.renderPrompt('ccAgentHelper', {
|
||||||
You help agents answer questions about patients, doctors, appointments, clinics, and hospital services during live calls.
|
hospitalName: this.getHospitalName(),
|
||||||
|
knowledgeBase: kb,
|
||||||
IMPORTANT — ANSWER FROM KNOWLEDGE BASE FIRST:
|
});
|
||||||
The knowledge base below contains REAL clinic locations, timings, doctor details, health packages, and insurance partners.
|
|
||||||
When asked about clinic timings, locations, doctor availability, packages, or insurance — ALWAYS check the knowledge base FIRST before saying you don't know.
|
|
||||||
Example: "What are the Koramangala timings?" → Look for "Koramangala" in the Clinics section below.
|
|
||||||
|
|
||||||
RULES:
|
|
||||||
1. For patient-specific questions (history, appointments, calls), use the lookup tools. NEVER guess patient data.
|
|
||||||
2. For doctor details beyond what's in the KB, use the lookup_doctor tool.
|
|
||||||
3. For clinic info, timings, packages, insurance → answer directly from the knowledge base below.
|
|
||||||
4. If you truly cannot find the answer in the KB or via tools, say "I couldn't find that in our system."
|
|
||||||
5. Be concise — agents are on live calls. Under 100 words unless asked for detail.
|
|
||||||
6. NEVER give medical advice, diagnosis, or treatment recommendations.
|
|
||||||
7. Format with bullet points for easy scanning.
|
|
||||||
|
|
||||||
KNOWLEDGE BASE (this is real data from our system):
|
|
||||||
${kb}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async chatWithTools(userMessage: string, auth: string) {
|
private async chatWithTools(userMessage: string, auth: string) {
|
||||||
@@ -844,16 +992,15 @@ ${kb}`;
|
|||||||
`{ doctors(first: 10) { edges { node {
|
`{ doctors(first: 10) { edges { node {
|
||||||
id name fullName { firstName lastName }
|
id name fullName { firstName lastName }
|
||||||
department specialty qualifications yearsOfExperience
|
department specialty qualifications yearsOfExperience
|
||||||
visitingHours
|
|
||||||
consultationFeeNew { amountMicros currencyCode }
|
consultationFeeNew { amountMicros currencyCode }
|
||||||
consultationFeeFollowUp { amountMicros currencyCode }
|
consultationFeeFollowUp { amountMicros currencyCode }
|
||||||
active registrationNumber
|
active registrationNumber
|
||||||
clinic { id name clinicName }
|
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, auth,
|
undefined, auth,
|
||||||
);
|
);
|
||||||
|
|
||||||
const doctors = data.doctors.edges.map((e: any) => e.node);
|
const doctors = normalizeDoctors(data.doctors.edges.map((e: any) => e.node));
|
||||||
const search = doctorName.toLowerCase();
|
const search = doctorName.toLowerCase();
|
||||||
const matched = doctors.filter((d: any) => {
|
const matched = doctors.filter((d: any) => {
|
||||||
const full = `${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''} ${d.name ?? ''}`.toLowerCase();
|
const full = `${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''} ${d.name ?? ''}`.toLowerCase();
|
||||||
@@ -866,7 +1013,13 @@ ${kb}`;
|
|||||||
found: true,
|
found: true,
|
||||||
doctors: matched.map((d: any) => ({
|
doctors: matched.map((d: any) => ({
|
||||||
...d,
|
...d,
|
||||||
clinicName: d.clinic?.clinicName ?? d.clinic?.name ?? 'N/A',
|
// Multi-clinic doctors show as
|
||||||
|
// "Koramangala / Indiranagar" so the
|
||||||
|
// model has the full picture without
|
||||||
|
// a follow-up tool call.
|
||||||
|
clinicName: d.clinics.length > 0
|
||||||
|
? d.clinics.map((c: { clinicName: string }) => c.clinicName).join(' / ')
|
||||||
|
: 'N/A',
|
||||||
feeNewFormatted: d.consultationFeeNew ? `₹${d.consultationFeeNew.amountMicros / 1_000_000}` : 'N/A',
|
feeNewFormatted: d.consultationFeeNew ? `₹${d.consultationFeeNew.amountMicros / 1_000_000}` : 'N/A',
|
||||||
feeFollowUpFormatted: d.consultationFeeFollowUp ? `₹${d.consultationFeeFollowUp.amountMicros / 1_000_000}` : 'N/A',
|
feeFollowUpFormatted: d.consultationFeeFollowUp ? `₹${d.consultationFeeFollowUp.amountMicros / 1_000_000}` : 'N/A',
|
||||||
})),
|
})),
|
||||||
@@ -890,13 +1043,13 @@ ${kb}`;
|
|||||||
try {
|
try {
|
||||||
const doctors = await this.platform.queryWithAuth<any>(
|
const doctors = await this.platform.queryWithAuth<any>(
|
||||||
`{ doctors(first: 10) { edges { node {
|
`{ doctors(first: 10) { edges { node {
|
||||||
name fullName { firstName lastName } department specialty visitingHours
|
id name fullName { firstName lastName } department specialty
|
||||||
consultationFeeNew { amountMicros currencyCode }
|
consultationFeeNew { amountMicros currencyCode }
|
||||||
clinic { name clinicName }
|
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, auth,
|
undefined, auth,
|
||||||
);
|
);
|
||||||
const docs = doctors.doctors.edges.map((e: any) => e.node);
|
const docs = normalizeDoctors(doctors.doctors.edges.map((e: any) => e.node));
|
||||||
const l = msg.toLowerCase();
|
const l = msg.toLowerCase();
|
||||||
|
|
||||||
const matchedDoc = docs.find((d: any) => {
|
const matchedDoc = docs.find((d: any) => {
|
||||||
@@ -906,7 +1059,7 @@ ${kb}`;
|
|||||||
if (matchedDoc) {
|
if (matchedDoc) {
|
||||||
const fee = matchedDoc.consultationFeeNew ? `₹${matchedDoc.consultationFeeNew.amountMicros / 1_000_000}` : '';
|
const fee = matchedDoc.consultationFeeNew ? `₹${matchedDoc.consultationFeeNew.amountMicros / 1_000_000}` : '';
|
||||||
const clinic = matchedDoc.clinic?.clinicName ?? '';
|
const clinic = matchedDoc.clinic?.clinicName ?? '';
|
||||||
return `Dr. ${matchedDoc.fullName?.lastName ?? matchedDoc.name} (${matchedDoc.department ?? matchedDoc.specialty}): ${matchedDoc.visitingHours ?? 'hours not set'}${clinic ? ` at ${clinic}` : ''}${fee ? `. Fee: ${fee}` : ''}.`;
|
return `Dr. ${matchedDoc.fullName?.lastName ?? matchedDoc.name} (${matchedDoc.department ?? matchedDoc.specialty}): ${matchedDoc.visitingHours || 'hours not set'}${clinic ? ` at ${clinic}` : ''}${fee ? `. Fee: ${fee}` : ''}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (l.includes('doctor') || l.includes('available')) {
|
if (l.includes('doctor') || l.includes('available')) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { generateObject } from 'ai';
|
|||||||
import type { LanguageModel } from 'ai';
|
import type { LanguageModel } from 'ai';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { createAiModel } from './ai-provider';
|
import { createAiModel } from './ai-provider';
|
||||||
|
import { AiConfigService } from '../config/ai-config.service';
|
||||||
|
|
||||||
type LeadContext = {
|
type LeadContext = {
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
@@ -32,8 +33,17 @@ export class AiEnrichmentService {
|
|||||||
private readonly logger = new Logger(AiEnrichmentService.name);
|
private readonly logger = new Logger(AiEnrichmentService.name);
|
||||||
private readonly aiModel: LanguageModel | null;
|
private readonly aiModel: LanguageModel | null;
|
||||||
|
|
||||||
constructor(private config: ConfigService) {
|
constructor(
|
||||||
this.aiModel = createAiModel(config);
|
private config: ConfigService,
|
||||||
|
private aiConfig: AiConfigService,
|
||||||
|
) {
|
||||||
|
const cfg = aiConfig.getConfig();
|
||||||
|
this.aiModel = createAiModel({
|
||||||
|
provider: cfg.provider,
|
||||||
|
model: cfg.model,
|
||||||
|
anthropicApiKey: config.get<string>('ai.anthropicApiKey'),
|
||||||
|
openaiApiKey: config.get<string>('ai.openaiApiKey'),
|
||||||
|
});
|
||||||
if (!this.aiModel) {
|
if (!this.aiModel) {
|
||||||
this.logger.warn('AI not configured — enrichment uses fallback');
|
this.logger.warn('AI not configured — enrichment uses fallback');
|
||||||
}
|
}
|
||||||
@@ -56,19 +66,15 @@ export class AiEnrichmentService {
|
|||||||
const { object } = await generateObject({
|
const { object } = await generateObject({
|
||||||
model: this.aiModel!,
|
model: this.aiModel!,
|
||||||
schema: enrichmentSchema,
|
schema: enrichmentSchema,
|
||||||
prompt: `You are an AI assistant for a hospital call center.
|
prompt: this.aiConfig.renderPrompt('leadEnrichment', {
|
||||||
An inbound call is coming in from a lead. Summarize their history and suggest what the call center agent should do.
|
leadName: `${lead.firstName ?? 'Unknown'} ${lead.lastName ?? ''}`.trim(),
|
||||||
|
leadSource: lead.leadSource ?? 'Unknown',
|
||||||
Lead details:
|
interestedService: lead.interestedService ?? 'Unknown',
|
||||||
- Name: ${lead.firstName ?? 'Unknown'} ${lead.lastName ?? ''}
|
leadStatus: lead.leadStatus ?? 'Unknown',
|
||||||
- Source: ${lead.leadSource ?? 'Unknown'}
|
daysSince,
|
||||||
- Interested in: ${lead.interestedService ?? 'Unknown'}
|
contactAttempts: lead.contactAttempts ?? 0,
|
||||||
- Current status: ${lead.leadStatus ?? 'Unknown'}
|
activities: activitiesText,
|
||||||
- Lead age: ${daysSince} days
|
}),
|
||||||
- Contact attempts: ${lead.contactAttempts ?? 0}
|
|
||||||
|
|
||||||
Recent activity:
|
|
||||||
${activitiesText}`,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`AI enrichment generated for lead ${lead.firstName} ${lead.lastName}`);
|
this.logger.log(`AI enrichment generated for lead ${lead.firstName} ${lead.lastName}`);
|
||||||
|
|||||||
@@ -1,26 +1,30 @@
|
|||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { anthropic } from '@ai-sdk/anthropic';
|
import { anthropic } from '@ai-sdk/anthropic';
|
||||||
import { openai } from '@ai-sdk/openai';
|
import { openai } from '@ai-sdk/openai';
|
||||||
import type { LanguageModel } from 'ai';
|
import type { LanguageModel } from 'ai';
|
||||||
|
|
||||||
export function createAiModel(config: ConfigService): LanguageModel | null {
|
// Pure factory — no DI. Caller passes provider/model (admin-editable, from
|
||||||
const provider = config.get<string>('ai.provider') ?? 'openai';
|
// AiConfigService) and the API key (env-driven, ops-owned). Decoupling means
|
||||||
const model = config.get<string>('ai.model') ?? 'gpt-4o-mini';
|
// the model can be re-built per request without re-instantiating the caller
|
||||||
|
// service, so admin updates to provider/model take effect immediately.
|
||||||
|
|
||||||
if (provider === 'anthropic') {
|
export type AiProviderOpts = {
|
||||||
const apiKey = config.get<string>('ai.anthropicApiKey');
|
provider: string;
|
||||||
if (!apiKey) return null;
|
model: string;
|
||||||
return anthropic(model);
|
anthropicApiKey?: string;
|
||||||
|
openaiApiKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createAiModel(opts: AiProviderOpts): LanguageModel | null {
|
||||||
|
if (opts.provider === 'anthropic') {
|
||||||
|
if (!opts.anthropicApiKey) return null;
|
||||||
|
return anthropic(opts.model);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default to openai
|
// Default to openai
|
||||||
const apiKey = config.get<string>('ai.openaiApiKey');
|
if (!opts.openaiApiKey) return null;
|
||||||
if (!apiKey) return null;
|
return openai(opts.model);
|
||||||
return openai(model);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAiConfigured(config: ConfigService): boolean {
|
export function isAiConfigured(opts: AiProviderOpts): boolean {
|
||||||
const provider = config.get<string>('ai.provider') ?? 'openai';
|
if (opts.provider === 'anthropic') return !!opts.anthropicApiKey;
|
||||||
if (provider === 'anthropic') return !!config.get<string>('ai.anthropicApiKey');
|
return !!opts.openaiApiKey;
|
||||||
return !!config.get<string>('ai.openaiApiKey');
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { PlatformModule } from '../platform/platform.module';
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
import { AiEnrichmentService } from './ai-enrichment.service';
|
import { AiEnrichmentService } from './ai-enrichment.service';
|
||||||
import { AiChatController } from './ai-chat.controller';
|
import { AiChatController } from './ai-chat.controller';
|
||||||
|
import { CallerResolutionModule } from '../caller/caller-resolution.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule],
|
imports: [PlatformModule, forwardRef(() => CallerResolutionModule)],
|
||||||
controllers: [AiChatController],
|
controllers: [AiChatController],
|
||||||
providers: [AiEnrichmentService],
|
providers: [AiEnrichmentService],
|
||||||
exports: [AiEnrichmentService],
|
exports: [AiEnrichmentService],
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ import { EventsModule } from './events/events.module';
|
|||||||
import { CallerResolutionModule } from './caller/caller-resolution.module';
|
import { CallerResolutionModule } from './caller/caller-resolution.module';
|
||||||
import { RulesEngineModule } from './rules-engine/rules-engine.module';
|
import { RulesEngineModule } from './rules-engine/rules-engine.module';
|
||||||
import { ConfigThemeModule } from './config/config-theme.module';
|
import { ConfigThemeModule } from './config/config-theme.module';
|
||||||
|
import { WidgetModule } from './widget/widget.module';
|
||||||
|
import { TeamModule } from './team/team.module';
|
||||||
|
import { MasterdataModule } from './masterdata/masterdata.module';
|
||||||
|
import { LeadsModule } from './leads/leads.module';
|
||||||
|
import { TelephonyRegistrationService } from './telephony-registration.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -44,6 +49,11 @@ import { ConfigThemeModule } from './config/config-theme.module';
|
|||||||
CallerResolutionModule,
|
CallerResolutionModule,
|
||||||
RulesEngineModule,
|
RulesEngineModule,
|
||||||
ConfigThemeModule,
|
ConfigThemeModule,
|
||||||
|
WidgetModule,
|
||||||
|
TeamModule,
|
||||||
|
MasterdataModule,
|
||||||
|
LeadsModule,
|
||||||
],
|
],
|
||||||
|
providers: [TelephonyRegistrationService],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||||
|
|
||||||
export type AgentConfig = {
|
export type AgentConfig = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,15 +16,24 @@ export type AgentConfig = {
|
|||||||
export class AgentConfigService {
|
export class AgentConfigService {
|
||||||
private readonly logger = new Logger(AgentConfigService.name);
|
private readonly logger = new Logger(AgentConfigService.name);
|
||||||
private readonly cache = new Map<string, AgentConfig>();
|
private readonly cache = new Map<string, AgentConfig>();
|
||||||
private readonly sipDomain: string;
|
|
||||||
private readonly sipWsPort: string;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private platform: PlatformGraphqlService,
|
private platform: PlatformGraphqlService,
|
||||||
private config: ConfigService,
|
private telephony: TelephonyConfigService,
|
||||||
) {
|
) {}
|
||||||
this.sipDomain = config.get<string>('sip.domain', 'blr-pub-rtc4.ozonetel.com');
|
|
||||||
this.sipWsPort = config.get<string>('sip.wsPort', '444');
|
private get sipDomain(): string {
|
||||||
|
return this.telephony.getConfig().sip.domain || 'blr-pub-rtc4.ozonetel.com';
|
||||||
|
}
|
||||||
|
private get sipWsPort(): string {
|
||||||
|
return this.telephony.getConfig().sip.wsPort || '444';
|
||||||
|
}
|
||||||
|
private get defaultCampaignName(): string {
|
||||||
|
// No hardcoded fallback — each Agent entity's own campaignName
|
||||||
|
// field is the source of truth. Env var is the per-workspace
|
||||||
|
// default; if neither is set, the Ozonetel login will use
|
||||||
|
// whatever the agent's entity specifies.
|
||||||
|
return this.telephony.getConfig().ozonetel.campaignName || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async getByMemberId(memberId: string): Promise<AgentConfig | null> {
|
async getByMemberId(memberId: string): Promise<AgentConfig | null> {
|
||||||
@@ -32,22 +41,29 @@ export class AgentConfigService {
|
|||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Note: platform GraphQL field names are derived from the SDK
|
||||||
|
// `label`, not `name` — so the filter/column is
|
||||||
|
// `workspaceMemberId` and the SIP fields are camelCase. The
|
||||||
|
// legacy staging workspace was synced from an older SDK that
|
||||||
|
// exposed `wsmemberId`/`ozonetelagentid`/etc., but any fresh
|
||||||
|
// sync (and all new hospitals going forward) uses these
|
||||||
|
// label-derived names. Re-sync staging if it drifts.
|
||||||
const data = await this.platform.query<any>(
|
const data = await this.platform.query<any>(
|
||||||
`{ agents(first: 1, filter: { wsmemberId: { eq: "${memberId}" } }) { edges { node {
|
`{ agents(first: 1, filter: { workspaceMemberId: { eq: "${memberId}" } }) { edges { node {
|
||||||
id ozonetelagentid sipextension sippassword campaignname
|
id ozonetelAgentId sipExtension sipPassword campaignName
|
||||||
} } } }`,
|
} } } }`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const node = data?.agents?.edges?.[0]?.node;
|
const node = data?.agents?.edges?.[0]?.node;
|
||||||
if (!node || !node.ozonetelagentid || !node.sipextension) return null;
|
if (!node || !node.ozonetelAgentId || !node.sipExtension) return null;
|
||||||
|
|
||||||
const agentConfig: AgentConfig = {
|
const agentConfig: AgentConfig = {
|
||||||
id: node.id,
|
id: node.id,
|
||||||
ozonetelAgentId: node.ozonetelagentid,
|
ozonetelAgentId: node.ozonetelAgentId,
|
||||||
sipExtension: node.sipextension,
|
sipExtension: node.sipExtension,
|
||||||
sipPassword: node.sippassword ?? node.sipextension,
|
sipPassword: node.sipPassword ?? node.sipExtension,
|
||||||
campaignName: node.campaignname ?? process.env.OZONETEL_CAMPAIGN_NAME ?? 'Inbound_918041763265',
|
campaignName: node.campaignName ?? this.defaultCampaignName,
|
||||||
sipUri: `sip:${node.sipextension}@${this.sipDomain}`,
|
sipUri: `sip:${node.sipExtension}@${this.sipDomain}`,
|
||||||
sipWsServer: `wss://${this.sipDomain}:${this.sipWsPort}`,
|
sipWsServer: `wss://${this.sipDomain}:${this.sipWsPort}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import axios from 'axios';
|
|||||||
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
||||||
import { SessionService } from './session.service';
|
import { SessionService } from './session.service';
|
||||||
import { AgentConfigService } from './agent-config.service';
|
import { AgentConfigService } from './agent-config.service';
|
||||||
|
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||||
|
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
@@ -18,6 +19,7 @@ export class AuthController {
|
|||||||
private ozonetelAgent: OzonetelAgentService,
|
private ozonetelAgent: OzonetelAgentService,
|
||||||
private sessionService: SessionService,
|
private sessionService: SessionService,
|
||||||
private agentConfigService: AgentConfigService,
|
private agentConfigService: AgentConfigService,
|
||||||
|
private telephony: TelephonyConfigService,
|
||||||
) {
|
) {
|
||||||
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
|
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
|
||||||
this.workspaceSubdomain = process.env.PLATFORM_WORKSPACE_SUBDOMAIN ?? 'fortytwo-dev';
|
this.workspaceSubdomain = process.env.PLATFORM_WORKSPACE_SUBDOMAIN ?? 'fortytwo-dev';
|
||||||
@@ -105,11 +107,9 @@ export class AuthController {
|
|||||||
|
|
||||||
// Determine app role from platform roles
|
// Determine app role from platform roles
|
||||||
let appRole = 'executive'; // default
|
let appRole = 'executive'; // default
|
||||||
if (roleLabels.includes('HelixEngage Manager')) {
|
if (roleLabels.includes('HelixEngage Manager') || roleLabels.includes('HelixEngage Supervisor')) {
|
||||||
appRole = 'admin';
|
appRole = 'admin';
|
||||||
} else if (roleLabels.includes('HelixEngage User')) {
|
} else if (roleLabels.includes('HelixEngage User')) {
|
||||||
// Distinguish CC agent from executive by email convention or config
|
|
||||||
// For now, emails containing 'cc' map to cc-agent
|
|
||||||
const email = workspaceMember?.userEmail ?? body.email;
|
const email = workspaceMember?.userEmail ?? body.email;
|
||||||
appRole = email.includes('cc') ? 'cc-agent' : 'executive';
|
appRole = email.includes('cc') ? 'cc-agent' : 'executive';
|
||||||
}
|
}
|
||||||
@@ -138,10 +138,9 @@ export class AuthController {
|
|||||||
this.logger.warn(`Ozonetel token refresh on login failed: ${err.message}`);
|
this.logger.warn(`Ozonetel token refresh on login failed: ${err.message}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
const ozAgentPassword = process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$';
|
|
||||||
this.ozonetelAgent.loginAgent({
|
this.ozonetelAgent.loginAgent({
|
||||||
agentId: agentConfig.ozonetelAgentId,
|
agentId: agentConfig.ozonetelAgentId,
|
||||||
password: ozAgentPassword,
|
password: agentConfig.sipPassword,
|
||||||
phoneNumber: agentConfig.sipExtension,
|
phoneNumber: agentConfig.sipExtension,
|
||||||
mode: 'blended',
|
mode: 'blended',
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
@@ -250,9 +249,14 @@ export class AuthController {
|
|||||||
await this.sessionService.unlockSession(agentConfig.ozonetelAgentId);
|
await this.sessionService.unlockSession(agentConfig.ozonetelAgentId);
|
||||||
this.logger.log(`Session unlocked for ${agentConfig.ozonetelAgentId}`);
|
this.logger.log(`Session unlocked for ${agentConfig.ozonetelAgentId}`);
|
||||||
|
|
||||||
this.ozonetelAgent.logoutAgent({
|
// Await the Ozonetel logout so it completes before the
|
||||||
|
// HTTP response returns. Without this, a fast re-login
|
||||||
|
// (e.g. "remember me" auto-fill) races the logout and
|
||||||
|
// the agent lands in "Telephony Unavailable" because
|
||||||
|
// Ozonetel receives login while still processing logout.
|
||||||
|
await this.ozonetelAgent.logoutAgent({
|
||||||
agentId: agentConfig.ozonetelAgentId,
|
agentId: agentConfig.ozonetelAgentId,
|
||||||
password: process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$',
|
password: agentConfig.sipPassword,
|
||||||
}).catch(err => this.logger.warn(`Ozonetel logout failed: ${err.message}`));
|
}).catch(err => this.logger.warn(`Ozonetel logout failed: ${err.message}`));
|
||||||
|
|
||||||
this.agentConfigService.clearCache(memberId);
|
this.agentConfigService.clearCache(memberId);
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import Redis from 'ioredis';
|
import Redis from 'ioredis';
|
||||||
|
|
||||||
const SESSION_TTL = 3600; // 1 hour
|
const SESSION_TTL = 3600; // 1 hour
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SessionService implements OnModuleInit {
|
export class SessionService {
|
||||||
private readonly logger = new Logger(SessionService.name);
|
private readonly logger = new Logger(SessionService.name);
|
||||||
private redis: Redis;
|
private readonly redis: Redis;
|
||||||
|
|
||||||
constructor(private config: ConfigService) {}
|
// Redis client is constructed eagerly (not in onModuleInit) so
|
||||||
|
// other services can call cache methods from THEIR onModuleInit
|
||||||
onModuleInit() {
|
// hooks. NestJS instantiates all providers before running any
|
||||||
|
// onModuleInit callback, so the client is guaranteed ready even
|
||||||
|
// when an earlier-firing module's init path touches the cache
|
||||||
|
// (e.g. WidgetConfigService → WidgetKeysService → setCachePersistent).
|
||||||
|
constructor(private config: ConfigService) {
|
||||||
const url = this.config.get<string>('redis.url', 'redis://localhost:6379');
|
const url = this.config.get<string>('redis.url', 'redis://localhost:6379');
|
||||||
this.redis = new Redis(url);
|
this.redis = new Redis(url, { lazyConnect: false });
|
||||||
this.redis.on('connect', () => this.logger.log('Redis connected'));
|
this.redis.on('connect', () => this.logger.log('Redis connected'));
|
||||||
this.redis.on('error', (err) => this.logger.error(`Redis error: ${err.message}`));
|
this.redis.on('error', (err) => this.logger.error(`Redis error: ${err.message}`));
|
||||||
}
|
}
|
||||||
@@ -51,6 +55,26 @@ export class SessionService implements OnModuleInit {
|
|||||||
await this.redis.del(this.key(agentId));
|
await this.redis.del(this.key(agentId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enumerate every active session lock so the maint UI can show which
|
||||||
|
// agentIds are currently held (and by whom) vs free. Uses SCAN, not
|
||||||
|
// KEYS, to avoid blocking Redis on workspaces with many keys.
|
||||||
|
async listLockedSessions(): Promise<Array<{ agentId: string; memberId: string; ip: string; lockedAt: string }>> {
|
||||||
|
const out: Array<{ agentId: string; memberId: string; ip: string; lockedAt: string }> = [];
|
||||||
|
const stream = this.redis.scanStream({ match: 'agent:session:*', count: 100 });
|
||||||
|
const keys: string[] = [];
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
stream.on('data', (chunk: string[]) => keys.push(...chunk));
|
||||||
|
stream.on('end', resolve);
|
||||||
|
stream.on('error', reject);
|
||||||
|
});
|
||||||
|
for (const key of keys) {
|
||||||
|
const agentId = key.slice('agent:session:'.length);
|
||||||
|
const session = await this.getSession(agentId);
|
||||||
|
if (session) out.push({ agentId, ...session });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// Generic cache operations for any module
|
// Generic cache operations for any module
|
||||||
async getCache(key: string): Promise<string | null> {
|
async getCache(key: string): Promise<string | null> {
|
||||||
return this.redis.get(key);
|
return this.redis.get(key);
|
||||||
@@ -60,6 +84,10 @@ export class SessionService implements OnModuleInit {
|
|||||||
await this.redis.set(key, value, 'EX', ttlSeconds);
|
await this.redis.set(key, value, 'EX', ttlSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setCachePersistent(key: string, value: string): Promise<void> {
|
||||||
|
await this.redis.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
async deleteCache(key: string): Promise<void> {
|
async deleteCache(key: string): Promise<void> {
|
||||||
await this.redis.del(key);
|
await this.redis.del(key);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { generateText } from 'ai';
|
|||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
import { createAiModel } from '../ai/ai-provider';
|
import { createAiModel } from '../ai/ai-provider';
|
||||||
import type { LanguageModel } from 'ai';
|
import type { LanguageModel } from 'ai';
|
||||||
|
import { AiConfigService } from '../config/ai-config.service';
|
||||||
|
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../shared/doctor-utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CallAssistService {
|
export class CallAssistService {
|
||||||
@@ -14,8 +16,15 @@ export class CallAssistService {
|
|||||||
constructor(
|
constructor(
|
||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
private platform: PlatformGraphqlService,
|
private platform: PlatformGraphqlService,
|
||||||
|
private aiConfig: AiConfigService,
|
||||||
) {
|
) {
|
||||||
this.aiModel = createAiModel(config);
|
const cfg = aiConfig.getConfig();
|
||||||
|
this.aiModel = createAiModel({
|
||||||
|
provider: cfg.provider,
|
||||||
|
model: cfg.model,
|
||||||
|
anthropicApiKey: config.get<string>('ai.anthropicApiKey'),
|
||||||
|
openaiApiKey: config.get<string>('ai.openaiApiKey'),
|
||||||
|
});
|
||||||
this.platformApiKey = config.get<string>('platform.apiKey') ?? '';
|
this.platformApiKey = config.get<string>('platform.apiKey') ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,16 +82,24 @@ export class CallAssistService {
|
|||||||
|
|
||||||
const docResult = await this.platform.queryWithAuth<any>(
|
const docResult = await this.platform.queryWithAuth<any>(
|
||||||
`{ doctors(first: 20) { edges { node {
|
`{ doctors(first: 20) { edges { node {
|
||||||
fullName { firstName lastName } department specialty clinic { clinicName }
|
id fullName { firstName lastName } department specialty
|
||||||
|
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, authHeader,
|
undefined, authHeader,
|
||||||
);
|
);
|
||||||
const docs = docResult.doctors.edges.map((e: any) => e.node);
|
const docs = normalizeDoctors(docResult.doctors.edges.map((e: any) => e.node));
|
||||||
if (docs.length > 0) {
|
if (docs.length > 0) {
|
||||||
parts.push('\nAVAILABLE DOCTORS:');
|
parts.push('\nAVAILABLE DOCTORS:');
|
||||||
for (const d of docs) {
|
for (const d of docs) {
|
||||||
const name = d.fullName ? `Dr. ${d.fullName.firstName} ${d.fullName.lastName}`.trim() : 'Unknown';
|
const name = d.fullName ? `Dr. ${d.fullName.firstName} ${d.fullName.lastName}`.trim() : 'Unknown';
|
||||||
parts.push(`- ${name} — ${d.department ?? '?'} — ${d.clinic?.clinicName ?? '?'}`);
|
// Show all clinics the doctor visits, joined with
|
||||||
|
// " / " — call assist context is read by the AI
|
||||||
|
// whisperer so multi-clinic doctors don't get
|
||||||
|
// truncated to their first location.
|
||||||
|
const clinicLabel = d.clinics.length > 0
|
||||||
|
? d.clinics.map((c) => c.clinicName).join(' / ')
|
||||||
|
: '?';
|
||||||
|
parts.push(`- ${name} — ${d.department ?? '?'} — ${clinicLabel}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,18 +116,10 @@ export class CallAssistService {
|
|||||||
try {
|
try {
|
||||||
const { text } = await generateText({
|
const { text } = await generateText({
|
||||||
model: this.aiModel,
|
model: this.aiModel,
|
||||||
system: `You are a real-time call assistant for Global Hospital Bangalore.
|
system: this.aiConfig.renderPrompt('callAssist', {
|
||||||
You listen to the customer's words and provide brief, actionable suggestions for the CC agent.
|
hospitalName: process.env.HOSPITAL_NAME ?? 'the hospital',
|
||||||
|
context,
|
||||||
${context}
|
}),
|
||||||
|
|
||||||
RULES:
|
|
||||||
- Keep suggestions under 2 sentences
|
|
||||||
- Focus on actionable next steps the agent should take NOW
|
|
||||||
- If customer mentions a doctor or department, suggest available slots
|
|
||||||
- If customer wants to cancel or reschedule, note relevant appointment details
|
|
||||||
- If customer sounds upset, suggest empathetic response
|
|
||||||
- Do NOT repeat what the agent already knows`,
|
|
||||||
prompt: `Conversation transcript so far:\n${transcript}\n\nProvide a brief suggestion for the agent based on what was just said.`,
|
prompt: `Conversation transcript so far:\n${transcript}\n\nProvide a brief suggestion for the agent based on what was just said.`,
|
||||||
maxOutputTokens: 150,
|
maxOutputTokens: 150,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PlatformModule } from '../platform/platform.module';
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
import { AiModule } from '../ai/ai.module';
|
import { AiModule } from '../ai/ai.module';
|
||||||
|
import { CallerResolutionModule } from '../caller/caller-resolution.module';
|
||||||
import { CallEventsService } from './call-events.service';
|
import { CallEventsService } from './call-events.service';
|
||||||
import { CallEventsGateway } from './call-events.gateway';
|
import { CallEventsGateway } from './call-events.gateway';
|
||||||
import { CallLookupController } from './call-lookup.controller';
|
import { CallLookupController } from './call-lookup.controller';
|
||||||
|
import { LeadEnrichController } from './lead-enrich.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule, AiModule],
|
// CallerResolutionModule is imported so LeadEnrichController can
|
||||||
controllers: [CallLookupController],
|
// inject CallerResolutionService to invalidate the Redis caller
|
||||||
|
// cache after a forced re-enrichment.
|
||||||
|
imports: [PlatformModule, AiModule, CallerResolutionModule],
|
||||||
|
controllers: [CallLookupController, LeadEnrichController],
|
||||||
providers: [CallEventsService, CallEventsGateway],
|
providers: [CallEventsService, CallEventsGateway],
|
||||||
exports: [CallEventsService, CallEventsGateway],
|
exports: [CallEventsService, CallEventsGateway],
|
||||||
})
|
})
|
||||||
|
|||||||
114
src/call-events/lead-enrich.controller.ts
Normal file
114
src/call-events/lead-enrich.controller.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { Body, Controller, Headers, HttpException, Logger, Param, Post } from '@nestjs/common';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { AiEnrichmentService } from '../ai/ai-enrichment.service';
|
||||||
|
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||||
|
|
||||||
|
// POST /api/lead/:id/enrich
|
||||||
|
//
|
||||||
|
// Force re-generation of a lead's AI summary + suggested action. Used by
|
||||||
|
// the call-desk appointment/enquiry forms when the agent explicitly edits
|
||||||
|
// the caller's name — the previously-generated summary was built against
|
||||||
|
// the stale identity, so we discard it and run the enrichment prompt
|
||||||
|
// again with the corrected name.
|
||||||
|
//
|
||||||
|
// Optional body: `{ phone?: string }` — when provided, also invalidates
|
||||||
|
// the Redis caller-resolution cache for that phone so the NEXT incoming
|
||||||
|
// call from the same number picks up fresh data from the platform
|
||||||
|
// instead of the stale cached entry.
|
||||||
|
//
|
||||||
|
// This is distinct from the cache-miss enrichment path in
|
||||||
|
// call-lookup.controller.ts `POST /api/call/lookup` which only runs
|
||||||
|
// enrichment when `lead.aiSummary` is null. That path is fine for
|
||||||
|
// first-time lookups; this one is for explicit "the old summary is
|
||||||
|
// wrong, regenerate it" triggers.
|
||||||
|
@Controller('api/lead')
|
||||||
|
export class LeadEnrichController {
|
||||||
|
private readonly logger = new Logger(LeadEnrichController.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly platform: PlatformGraphqlService,
|
||||||
|
private readonly ai: AiEnrichmentService,
|
||||||
|
private readonly callerResolution: CallerResolutionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post(':id/enrich')
|
||||||
|
async enrichLead(
|
||||||
|
@Param('id') leadId: string,
|
||||||
|
@Body() body: { phone?: string },
|
||||||
|
@Headers('authorization') authHeader: string,
|
||||||
|
) {
|
||||||
|
if (!authHeader) throw new HttpException('Authorization required', 401);
|
||||||
|
if (!leadId) throw new HttpException('leadId required', 400);
|
||||||
|
|
||||||
|
this.logger.log(`Force-enriching lead ${leadId}`);
|
||||||
|
|
||||||
|
// 1. Fetch fresh lead from platform (with the staging-aligned
|
||||||
|
// field names — see findLeadByIdWithToken comment).
|
||||||
|
let lead: any;
|
||||||
|
try {
|
||||||
|
lead = await this.platform.findLeadByIdWithToken(leadId, authHeader);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`[LEAD-ENRICH] Lead fetch failed for ${leadId}: ${err}`);
|
||||||
|
throw new HttpException(`Lead fetch failed: ${(err as Error).message}`, 500);
|
||||||
|
}
|
||||||
|
if (!lead) {
|
||||||
|
throw new HttpException(`Lead not found: ${leadId}`, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch recent activities so the prompt has conversation context.
|
||||||
|
let activities: any[] = [];
|
||||||
|
try {
|
||||||
|
activities = await this.platform.getLeadActivitiesWithToken(leadId, authHeader, 5);
|
||||||
|
} catch (err) {
|
||||||
|
// Non-fatal — enrichment just has less context.
|
||||||
|
this.logger.warn(`[LEAD-ENRICH] Activity fetch failed: ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Run enrichment. LeadContext uses the legacy `leadStatus`/
|
||||||
|
// `leadSource` internal names even though the platform now
|
||||||
|
// exposes them as `status`/`source` — we just map across.
|
||||||
|
const enrichment = await this.ai.enrichLead({
|
||||||
|
firstName: lead.contactName?.firstName ?? undefined,
|
||||||
|
lastName: lead.contactName?.lastName ?? undefined,
|
||||||
|
leadSource: lead.source ?? undefined,
|
||||||
|
interestedService: lead.interestedService ?? undefined,
|
||||||
|
leadStatus: lead.status ?? undefined,
|
||||||
|
contactAttempts: lead.contactAttempts ?? undefined,
|
||||||
|
createdAt: lead.createdAt,
|
||||||
|
activities: activities.map((a) => ({
|
||||||
|
activityType: a.activityType ?? '',
|
||||||
|
summary: a.summary ?? '',
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Persist the new summary back to the lead.
|
||||||
|
try {
|
||||||
|
await this.platform.updateLeadWithToken(
|
||||||
|
leadId,
|
||||||
|
{
|
||||||
|
aiSummary: enrichment.aiSummary,
|
||||||
|
aiSuggestedAction: enrichment.aiSuggestedAction,
|
||||||
|
},
|
||||||
|
authHeader,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`[LEAD-ENRICH] Failed to persist enrichment for ${leadId}: ${err}`);
|
||||||
|
throw new HttpException(
|
||||||
|
`Failed to persist enrichment: ${(err as Error).message}`,
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caller resolution no longer caches — every resolve() hits the
|
||||||
|
// platform fresh via an indexed phone filter. No invalidation
|
||||||
|
// needed after enrichment.
|
||||||
|
|
||||||
|
this.logger.log(`[LEAD-ENRICH] Lead ${leadId} enriched successfully`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
leadId,
|
||||||
|
aiSummary: enrichment.aiSummary,
|
||||||
|
aiSuggestedAction: enrichment.aiSuggestedAction,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,14 +23,4 @@ export class CallerResolutionController {
|
|||||||
const result = await this.resolution.resolve(phone, auth);
|
const result = await this.resolution.resolve(phone, auth);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('invalidate')
|
|
||||||
async invalidate(@Body('phone') phone: string) {
|
|
||||||
if (!phone) {
|
|
||||||
throw new HttpException('phone is required', HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
this.logger.log(`[RESOLVE] Invalidating cache for: ${phone}`);
|
|
||||||
await this.resolution.invalidate(phone);
|
|
||||||
return { status: 'ok' };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { PlatformModule } from '../platform/platform.module';
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { CallerResolutionController } from './caller-resolution.controller';
|
import { CallerResolutionController } from './caller-resolution.controller';
|
||||||
import { CallerResolutionService } from './caller-resolution.service';
|
import { CallerResolutionService } from './caller-resolution.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule, AuthModule],
|
imports: [PlatformModule, forwardRef(() => AuthModule)],
|
||||||
controllers: [CallerResolutionController],
|
controllers: [CallerResolutionController],
|
||||||
providers: [CallerResolutionService],
|
providers: [CallerResolutionService],
|
||||||
exports: [CallerResolutionService],
|
exports: [CallerResolutionService],
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
import { SessionService } from '../auth/session.service';
|
|
||||||
|
|
||||||
const CACHE_TTL = 3600; // 1 hour
|
|
||||||
const CACHE_PREFIX = 'caller:';
|
|
||||||
|
|
||||||
export type ResolvedCaller = {
|
export type ResolvedCaller = {
|
||||||
leadId: string;
|
leadId: string;
|
||||||
@@ -11,7 +7,7 @@ export type ResolvedCaller = {
|
|||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
isNew: boolean; // true if we just created the lead+patient pair
|
isNew: boolean; // true if no Lead/Patient exists for this phone
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -20,28 +16,24 @@ export class CallerResolutionService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly platform: PlatformGraphqlService,
|
private readonly platform: PlatformGraphqlService,
|
||||||
private readonly cache: SessionService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// Resolve a caller by phone number. Always returns a paired lead + patient.
|
// Resolve a caller by phone number via indexed platform queries. No
|
||||||
|
// cache — every call hits the DB fresh. Cache was previously used to
|
||||||
|
// compensate for client-side `leads(first: 200)` scans, but we now
|
||||||
|
// filter by phone directly which is O(log n) with the DB index.
|
||||||
|
// Cost: ~2 fast queries per resolve; eventual-consistency window = 0.
|
||||||
async resolve(phone: string, auth: string): Promise<ResolvedCaller> {
|
async resolve(phone: string, auth: string): Promise<ResolvedCaller> {
|
||||||
const normalized = phone.replace(/\D/g, '').slice(-10);
|
const normalized = phone.replace(/\D/g, '').slice(-10);
|
||||||
if (normalized.length < 10) {
|
if (normalized.length < 10) {
|
||||||
throw new Error(`Invalid phone number: ${phone}`);
|
throw new Error(`Invalid phone number: ${phone}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Check cache
|
// Lookup lead + patient by phone, in parallel.
|
||||||
const cached = await this.cache.getCache(`${CACHE_PREFIX}${normalized}`);
|
const [lead, patient] = await Promise.all([
|
||||||
if (cached) {
|
this.findLeadByPhone(normalized, auth),
|
||||||
this.logger.log(`[RESOLVE] Cache hit for ${normalized}`);
|
this.findPatientByPhone(normalized, auth),
|
||||||
return JSON.parse(cached);
|
]);
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Look up lead by phone
|
|
||||||
const lead = await this.findLeadByPhone(normalized, auth);
|
|
||||||
|
|
||||||
// 3. Look up patient by phone
|
|
||||||
const patient = await this.findPatientByPhone(normalized, auth);
|
|
||||||
|
|
||||||
let result: ResolvedCaller;
|
let result: ResolvedCaller;
|
||||||
|
|
||||||
@@ -51,6 +43,11 @@ export class CallerResolutionService {
|
|||||||
await this.linkLeadToPatient(lead.id, patient.id, auth);
|
await this.linkLeadToPatient(lead.id, patient.id, auth);
|
||||||
this.logger.log(`[RESOLVE] Linked existing lead ${lead.id} → patient ${patient.id}`);
|
this.logger.log(`[RESOLVE] Linked existing lead ${lead.id} → patient ${patient.id}`);
|
||||||
}
|
}
|
||||||
|
// PRD: "Returning patient (Y/N) will be taken care of by the system"
|
||||||
|
// Patient is recognized on a subsequent contact → mark as RETURNING
|
||||||
|
if (patient.patientType === 'NEW') {
|
||||||
|
this.upgradeToReturning(patient.id, auth);
|
||||||
|
}
|
||||||
result = {
|
result = {
|
||||||
leadId: lead.id,
|
leadId: lead.id,
|
||||||
patientId: patient.id,
|
patientId: patient.id,
|
||||||
@@ -76,6 +73,9 @@ export class CallerResolutionService {
|
|||||||
// Patient exists, no lead — create lead
|
// Patient exists, no lead — create lead
|
||||||
const newLead = await this.createLead(patient.firstName, patient.lastName, normalized, patient.id, auth);
|
const newLead = await this.createLead(patient.firstName, patient.lastName, normalized, patient.id, auth);
|
||||||
this.logger.log(`[RESOLVE] Created lead ${newLead.id} for existing patient ${patient.id}`);
|
this.logger.log(`[RESOLVE] Created lead ${newLead.id} for existing patient ${patient.id}`);
|
||||||
|
if (patient.patientType === 'NEW') {
|
||||||
|
this.upgradeToReturning(patient.id, auth);
|
||||||
|
}
|
||||||
result = {
|
result = {
|
||||||
leadId: newLead.id,
|
leadId: newLead.id,
|
||||||
patientId: patient.id,
|
patientId: patient.id,
|
||||||
@@ -85,13 +85,18 @@ export class CallerResolutionService {
|
|||||||
isNew: false,
|
isNew: false,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// Neither exists — create both
|
// Neither exists — return empty IDs with isNew=true. Caller
|
||||||
const newPatient = await this.createPatient('', '', normalized, auth);
|
// code is responsible for creating records with the real name
|
||||||
const newLead = await this.createLead('', '', normalized, newPatient.id, auth);
|
// they've collected (enquiry form, appointment form, widget,
|
||||||
this.logger.log(`[RESOLVE] Created new lead ${newLead.id} + patient ${newPatient.id} for ${normalized}`);
|
// AI tools). This avoids the "Unknown" placeholder cascade:
|
||||||
|
// no Lead/Patient is ever written unless we have a real name
|
||||||
|
// to attach to it. Missed-call / poller paths that have no
|
||||||
|
// name persist the Call record with leadName=phone as the
|
||||||
|
// honest snapshot.
|
||||||
|
this.logger.log(`[RESOLVE] No existing records for ${normalized} — returning isNew=true`);
|
||||||
result = {
|
result = {
|
||||||
leadId: newLead.id,
|
leadId: '',
|
||||||
patientId: newPatient.id,
|
patientId: '',
|
||||||
firstName: '',
|
firstName: '',
|
||||||
lastName: '',
|
lastName: '',
|
||||||
phone: normalized,
|
phone: normalized,
|
||||||
@@ -99,43 +104,30 @@ export class CallerResolutionService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Cache the result
|
|
||||||
await this.cache.setCache(`${CACHE_PREFIX}${normalized}`, JSON.stringify(result), CACHE_TTL);
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate cache for a phone number (call after updates)
|
// Indexed lookup — platform filters by phone server-side. Matches on
|
||||||
async invalidate(phone: string): Promise<void> {
|
// the last 10 digits regardless of stored format (+919XXXX / 91XXXX /
|
||||||
const normalized = phone.replace(/\D/g, '').slice(-10);
|
// XXXX / +91-XXXX), via the `like: "%XXXXXXXXXX"` predicate.
|
||||||
await this.cache.setCache(`${CACHE_PREFIX}${normalized}`, '', 1); // expire immediately
|
|
||||||
}
|
|
||||||
|
|
||||||
private async findLeadByPhone(phone10: string, auth: string): Promise<{ id: string; firstName: string; lastName: string; patientId: string | null } | null> {
|
private async findLeadByPhone(phone10: string, auth: string): Promise<{ id: string; firstName: string; lastName: string; patientId: string | null } | null> {
|
||||||
try {
|
try {
|
||||||
const data = await this.platform.queryWithAuth<{ leads: { edges: { node: any }[] } }>(
|
const data = await this.platform.queryWithAuth<{ leads: { edges: { node: any }[] } }>(
|
||||||
`{ leads(first: 200) { edges { node {
|
`{ leads(first: 1, filter: { contactPhone: { primaryPhoneNumber: { like: "%${phone10}" } } }) { edges { node {
|
||||||
id
|
id
|
||||||
contactName { firstName lastName }
|
contactName { firstName lastName }
|
||||||
contactPhone { primaryPhoneNumber }
|
|
||||||
patientId
|
patientId
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined,
|
undefined,
|
||||||
auth,
|
auth,
|
||||||
);
|
);
|
||||||
|
const match = data.leads.edges[0]?.node;
|
||||||
const match = data.leads.edges.find(e => {
|
|
||||||
const num = (e.node.contactPhone?.primaryPhoneNumber ?? '').replace(/\D/g, '').slice(-10);
|
|
||||||
return num.length >= 10 && num === phone10;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: match.node.id,
|
id: match.id,
|
||||||
firstName: match.node.contactName?.firstName ?? '',
|
firstName: match.contactName?.firstName ?? '',
|
||||||
lastName: match.node.contactName?.lastName ?? '',
|
lastName: match.contactName?.lastName ?? '',
|
||||||
patientId: match.node.patientId || null,
|
patientId: match.patientId || null,
|
||||||
};
|
};
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.warn(`[RESOLVE] Lead lookup failed: ${err.message}`);
|
this.logger.warn(`[RESOLVE] Lead lookup failed: ${err.message}`);
|
||||||
@@ -143,29 +135,24 @@ export class CallerResolutionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findPatientByPhone(phone10: string, auth: string): Promise<{ id: string; firstName: string; lastName: string } | null> {
|
private async findPatientByPhone(phone10: string, auth: string): Promise<{ id: string; firstName: string; lastName: string; patientType: string | null } | null> {
|
||||||
try {
|
try {
|
||||||
const data = await this.platform.queryWithAuth<{ patients: { edges: { node: any }[] } }>(
|
const data = await this.platform.queryWithAuth<{ patients: { edges: { node: any }[] } }>(
|
||||||
`{ patients(first: 200) { edges { node {
|
`{ patients(first: 1, filter: { phones: { primaryPhoneNumber: { like: "%${phone10}" } } }) { edges { node {
|
||||||
id
|
id
|
||||||
fullName { firstName lastName }
|
fullName { firstName lastName }
|
||||||
phones { primaryPhoneNumber }
|
patientType
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined,
|
undefined,
|
||||||
auth,
|
auth,
|
||||||
);
|
);
|
||||||
|
const match = data.patients.edges[0]?.node;
|
||||||
const match = data.patients.edges.find(e => {
|
|
||||||
const num = (e.node.phones?.primaryPhoneNumber ?? '').replace(/\D/g, '').slice(-10);
|
|
||||||
return num.length >= 10 && num === phone10;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: match.node.id,
|
id: match.id,
|
||||||
firstName: match.node.fullName?.firstName ?? '',
|
firstName: match.fullName?.firstName ?? '',
|
||||||
lastName: match.node.fullName?.lastName ?? '',
|
lastName: match.fullName?.lastName ?? '',
|
||||||
|
patientType: match.patientType ?? null,
|
||||||
};
|
};
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.warn(`[RESOLVE] Patient lookup failed: ${err.message}`);
|
this.logger.warn(`[RESOLVE] Patient lookup failed: ${err.message}`);
|
||||||
@@ -178,6 +165,7 @@ export class CallerResolutionService {
|
|||||||
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||||
{
|
{
|
||||||
data: {
|
data: {
|
||||||
|
name: `${firstName || 'Unknown'} ${lastName || ''}`.trim(),
|
||||||
fullName: { firstName: firstName || 'Unknown', lastName: lastName || '' },
|
fullName: { firstName: firstName || 'Unknown', lastName: lastName || '' },
|
||||||
phones: { primaryPhoneNumber: `+91${phone}` },
|
phones: { primaryPhoneNumber: `+91${phone}` },
|
||||||
patientType: 'NEW',
|
patientType: 'NEW',
|
||||||
@@ -206,6 +194,19 @@ export class CallerResolutionService {
|
|||||||
return data.createLead;
|
return data.createLead;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private upgradeToReturning(patientId: string, auth: string): void {
|
||||||
|
// Fire-and-forget — don't block caller resolution
|
||||||
|
this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: patientId, data: { patientType: 'RETURNING' } },
|
||||||
|
auth,
|
||||||
|
).then(() => {
|
||||||
|
this.logger.log(`[RESOLVE] Upgraded patient ${patientId} to RETURNING`);
|
||||||
|
}).catch(err => {
|
||||||
|
this.logger.warn(`[RESOLVE] Failed to upgrade patient type: ${err.message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async linkLeadToPatient(leadId: string, patientId: string, auth: string): Promise<void> {
|
private async linkLeadToPatient(leadId: string, patientId: string, auth: string): Promise<void> {
|
||||||
await this.platform.queryWithAuth<any>(
|
await this.platform.queryWithAuth<any>(
|
||||||
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
||||||
|
|||||||
213
src/caller/caller-resolution.spec.ts
Normal file
213
src/caller/caller-resolution.spec.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
/**
|
||||||
|
* Caller Resolution Service — unit tests
|
||||||
|
*
|
||||||
|
* QA coverage: TC-IB-05 (lead creation from enquiry),
|
||||||
|
* TC-IB-06 (new patient registration), TC-IB-07/08 (AI context)
|
||||||
|
*
|
||||||
|
* Tests the phone→lead+patient resolution logic:
|
||||||
|
* - Existing patient + existing lead → returns both, links if needed
|
||||||
|
* - Existing lead, no patient → creates patient, links
|
||||||
|
* - Existing patient, no lead → creates lead, links
|
||||||
|
* - New caller (neither exists) → creates both
|
||||||
|
* - Phone normalization (strips +91, non-digits)
|
||||||
|
* - Cache hit/miss behavior
|
||||||
|
*/
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { CallerResolutionService, ResolvedCaller } from './caller-resolution.service';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { SessionService } from '../auth/session.service';
|
||||||
|
|
||||||
|
describe('CallerResolutionService', () => {
|
||||||
|
let service: CallerResolutionService;
|
||||||
|
let platform: jest.Mocked<PlatformGraphqlService>;
|
||||||
|
let cache: jest.Mocked<SessionService>;
|
||||||
|
|
||||||
|
const AUTH = 'Bearer test-token';
|
||||||
|
|
||||||
|
const existingLead = {
|
||||||
|
id: 'lead-001',
|
||||||
|
contactName: { firstName: 'Priya', lastName: 'Sharma' },
|
||||||
|
contactPhone: { primaryPhoneNumber: '+919949879837' },
|
||||||
|
patientId: 'patient-001',
|
||||||
|
};
|
||||||
|
|
||||||
|
const existingPatient = {
|
||||||
|
id: 'patient-001',
|
||||||
|
fullName: { firstName: 'Priya', lastName: 'Sharma' },
|
||||||
|
phones: { primaryPhoneNumber: '+919949879837' },
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
CallerResolutionService,
|
||||||
|
{
|
||||||
|
provide: PlatformGraphqlService,
|
||||||
|
useValue: {
|
||||||
|
queryWithAuth: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: SessionService,
|
||||||
|
useValue: {
|
||||||
|
getCache: jest.fn().mockResolvedValue(null), // no cache by default
|
||||||
|
setCache: jest.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get(CallerResolutionService);
|
||||||
|
platform = module.get(PlatformGraphqlService);
|
||||||
|
cache = module.get(SessionService);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── TC-IB-05: Existing lead + existing patient ───────────────
|
||||||
|
|
||||||
|
it('TC-IB-05: should return existing lead+patient when both found by phone', async () => {
|
||||||
|
platform.queryWithAuth
|
||||||
|
// leads query
|
||||||
|
.mockResolvedValueOnce({ leads: { edges: [{ node: existingLead }] } })
|
||||||
|
// patients query
|
||||||
|
.mockResolvedValueOnce({ patients: { edges: [{ node: existingPatient }] } });
|
||||||
|
|
||||||
|
const result = await service.resolve('9949879837', AUTH);
|
||||||
|
|
||||||
|
expect(result.leadId).toBe('lead-001');
|
||||||
|
expect(result.patientId).toBe('patient-001');
|
||||||
|
expect(result.isNew).toBe(false);
|
||||||
|
expect(result.firstName).toBe('Priya');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── TC-IB-06: New caller → creates both lead + patient ──────
|
||||||
|
|
||||||
|
it('TC-IB-06: should create both lead+patient for unknown caller', async () => {
|
||||||
|
platform.queryWithAuth
|
||||||
|
// leads query → empty
|
||||||
|
.mockResolvedValueOnce({ leads: { edges: [] } })
|
||||||
|
// patients query → empty
|
||||||
|
.mockResolvedValueOnce({ patients: { edges: [] } })
|
||||||
|
// createPatient
|
||||||
|
.mockResolvedValueOnce({ createPatient: { id: 'new-patient-001' } })
|
||||||
|
// createLead
|
||||||
|
.mockResolvedValueOnce({ createLead: { id: 'new-lead-001' } });
|
||||||
|
|
||||||
|
const result = await service.resolve('6309248884', AUTH);
|
||||||
|
|
||||||
|
expect(result.leadId).toBe('new-lead-001');
|
||||||
|
expect(result.patientId).toBe('new-patient-001');
|
||||||
|
expect(result.isNew).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Lead exists, no patient → creates patient ────────────────
|
||||||
|
|
||||||
|
it('should create patient when lead exists without patient match', async () => {
|
||||||
|
const leadNoPatient = { ...existingLead, patientId: null };
|
||||||
|
|
||||||
|
platform.queryWithAuth
|
||||||
|
.mockResolvedValueOnce({ leads: { edges: [{ node: leadNoPatient }] } })
|
||||||
|
.mockResolvedValueOnce({ patients: { edges: [] } })
|
||||||
|
// createPatient
|
||||||
|
.mockResolvedValueOnce({ createPatient: { id: 'new-patient-002' } })
|
||||||
|
// linkLeadToPatient (updateLead)
|
||||||
|
.mockResolvedValueOnce({ updateLead: { id: 'lead-001' } });
|
||||||
|
|
||||||
|
const result = await service.resolve('9949879837', AUTH);
|
||||||
|
|
||||||
|
expect(result.patientId).toBe('new-patient-002');
|
||||||
|
expect(result.leadId).toBe('lead-001');
|
||||||
|
expect(result.isNew).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Patient exists, no lead → creates lead ───────────────────
|
||||||
|
|
||||||
|
it('should create lead when patient exists without lead match', async () => {
|
||||||
|
platform.queryWithAuth
|
||||||
|
.mockResolvedValueOnce({ leads: { edges: [] } })
|
||||||
|
.mockResolvedValueOnce({ patients: { edges: [{ node: existingPatient }] } })
|
||||||
|
// createLead
|
||||||
|
.mockResolvedValueOnce({ createLead: { id: 'new-lead-002' } });
|
||||||
|
|
||||||
|
const result = await service.resolve('9949879837', AUTH);
|
||||||
|
|
||||||
|
expect(result.leadId).toBe('new-lead-002');
|
||||||
|
expect(result.patientId).toBe('patient-001');
|
||||||
|
expect(result.isNew).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Phone normalization ──────────────────────────────────────
|
||||||
|
|
||||||
|
it('should normalize phone: strip +91 prefix and non-digits', async () => {
|
||||||
|
platform.queryWithAuth
|
||||||
|
.mockResolvedValueOnce({ leads: { edges: [] } })
|
||||||
|
.mockResolvedValueOnce({ patients: { edges: [] } })
|
||||||
|
.mockResolvedValueOnce({ createPatient: { id: 'p' } })
|
||||||
|
.mockResolvedValueOnce({ createLead: { id: 'l' } });
|
||||||
|
|
||||||
|
const result = await service.resolve('+91-994-987-9837', AUTH);
|
||||||
|
|
||||||
|
expect(result.phone).toBe('9949879837');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid short phone numbers', async () => {
|
||||||
|
await expect(service.resolve('12345', AUTH)).rejects.toThrow('Invalid phone');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Cache hit ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('should return cached result without hitting platform', async () => {
|
||||||
|
const cached: ResolvedCaller = {
|
||||||
|
leadId: 'cached-lead',
|
||||||
|
patientId: 'cached-patient',
|
||||||
|
firstName: 'Cache',
|
||||||
|
lastName: 'Hit',
|
||||||
|
phone: '9949879837',
|
||||||
|
isNew: false,
|
||||||
|
};
|
||||||
|
cache.getCache.mockResolvedValueOnce(JSON.stringify(cached));
|
||||||
|
|
||||||
|
const result = await service.resolve('9949879837', AUTH);
|
||||||
|
|
||||||
|
expect(result).toEqual(cached);
|
||||||
|
expect(platform.queryWithAuth).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Cache write ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('should cache result after successful resolve', async () => {
|
||||||
|
platform.queryWithAuth
|
||||||
|
.mockResolvedValueOnce({ leads: { edges: [{ node: existingLead }] } })
|
||||||
|
.mockResolvedValueOnce({ patients: { edges: [{ node: existingPatient }] } });
|
||||||
|
|
||||||
|
await service.resolve('9949879837', AUTH);
|
||||||
|
|
||||||
|
expect(cache.setCache).toHaveBeenCalledWith(
|
||||||
|
'caller:9949879837',
|
||||||
|
expect.any(String),
|
||||||
|
3600,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Links unlinked lead to patient ───────────────────────────
|
||||||
|
|
||||||
|
it('should link lead to patient when both exist but are unlinked', async () => {
|
||||||
|
const unlinkedLead = { ...existingLead, patientId: null };
|
||||||
|
|
||||||
|
platform.queryWithAuth
|
||||||
|
.mockResolvedValueOnce({ leads: { edges: [{ node: unlinkedLead }] } })
|
||||||
|
.mockResolvedValueOnce({ patients: { edges: [{ node: existingPatient }] } })
|
||||||
|
// updateLead to link
|
||||||
|
.mockResolvedValueOnce({ updateLead: { id: 'lead-001' } });
|
||||||
|
|
||||||
|
const result = await service.resolve('9949879837', AUTH);
|
||||||
|
|
||||||
|
expect(result.leadId).toBe('lead-001');
|
||||||
|
expect(result.patientId).toBe('patient-001');
|
||||||
|
|
||||||
|
// Verify the link mutation was called
|
||||||
|
const linkCall = platform.queryWithAuth.mock.calls.find(
|
||||||
|
c => typeof c[0] === 'string' && c[0].includes('updateLead'),
|
||||||
|
);
|
||||||
|
expect(linkCall).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
49
src/config/ai-config.controller.ts
Normal file
49
src/config/ai-config.controller.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { Body, Controller, Get, Logger, Param, Post, Put } from '@nestjs/common';
|
||||||
|
import { AiConfigService } from './ai-config.service';
|
||||||
|
import type { AiActorKey, AiConfig } from './ai.defaults';
|
||||||
|
|
||||||
|
// Mounted under /api/config alongside theme/widget/telephony/setup-state.
|
||||||
|
//
|
||||||
|
// GET /api/config/ai — full config (no secrets here, all safe to return)
|
||||||
|
// PUT /api/config/ai — admin update (provider/model/temperature)
|
||||||
|
// POST /api/config/ai/reset — reset entire config to defaults
|
||||||
|
// PUT /api/config/ai/prompts/:actor — update one persona's system prompt template
|
||||||
|
// POST /api/config/ai/prompts/:actor/reset — restore one persona to its default
|
||||||
|
@Controller('api/config')
|
||||||
|
export class AiConfigController {
|
||||||
|
private readonly logger = new Logger(AiConfigController.name);
|
||||||
|
|
||||||
|
constructor(private readonly ai: AiConfigService) {}
|
||||||
|
|
||||||
|
@Get('ai')
|
||||||
|
getAi() {
|
||||||
|
return this.ai.getConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('ai')
|
||||||
|
updateAi(@Body() body: Partial<AiConfig>) {
|
||||||
|
this.logger.log('AI config update request');
|
||||||
|
return this.ai.updateConfig(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('ai/reset')
|
||||||
|
resetAi() {
|
||||||
|
this.logger.log('AI config reset request');
|
||||||
|
return this.ai.resetConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('ai/prompts/:actor')
|
||||||
|
updatePrompt(
|
||||||
|
@Param('actor') actor: AiActorKey,
|
||||||
|
@Body() body: { template: string; editedBy?: string },
|
||||||
|
) {
|
||||||
|
this.logger.log(`AI prompt update for actor '${actor}'`);
|
||||||
|
return this.ai.updatePrompt(actor, body.template, body.editedBy ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('ai/prompts/:actor/reset')
|
||||||
|
resetPrompt(@Param('actor') actor: AiActorKey) {
|
||||||
|
this.logger.log(`AI prompt reset for actor '${actor}'`);
|
||||||
|
return this.ai.resetPrompt(actor);
|
||||||
|
}
|
||||||
|
}
|
||||||
218
src/config/ai-config.service.ts
Normal file
218
src/config/ai-config.service.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
import {
|
||||||
|
AI_ACTOR_KEYS,
|
||||||
|
AI_ENV_SEEDS,
|
||||||
|
DEFAULT_AI_CONFIG,
|
||||||
|
DEFAULT_AI_PROMPTS,
|
||||||
|
type AiActorKey,
|
||||||
|
type AiConfig,
|
||||||
|
type AiPromptConfig,
|
||||||
|
type AiProvider,
|
||||||
|
} from './ai.defaults';
|
||||||
|
|
||||||
|
const CONFIG_PATH = join(process.cwd(), 'data', 'ai.json');
|
||||||
|
const BACKUP_DIR = join(process.cwd(), 'data', 'ai-backups');
|
||||||
|
|
||||||
|
// File-backed AI config — provider, model, temperature, and per-actor
|
||||||
|
// system prompt templates. API keys stay in env. Mirrors
|
||||||
|
// TelephonyConfigService.
|
||||||
|
@Injectable()
|
||||||
|
export class AiConfigService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(AiConfigService.name);
|
||||||
|
private cached: AiConfig | null = null;
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
this.ensureReady();
|
||||||
|
}
|
||||||
|
|
||||||
|
getConfig(): AiConfig {
|
||||||
|
if (this.cached) return this.cached;
|
||||||
|
return this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateConfig(updates: Partial<AiConfig>): AiConfig {
|
||||||
|
const current = this.getConfig();
|
||||||
|
const merged: AiConfig = {
|
||||||
|
...current,
|
||||||
|
...updates,
|
||||||
|
// Clamp temperature to a sane range so an admin typo can't break
|
||||||
|
// the model — most providers reject < 0 or > 2.
|
||||||
|
temperature:
|
||||||
|
updates.temperature !== undefined
|
||||||
|
? Math.max(0, Math.min(2, updates.temperature))
|
||||||
|
: current.temperature,
|
||||||
|
version: (current.version ?? 0) + 1,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.backup();
|
||||||
|
this.writeFile(merged);
|
||||||
|
this.cached = merged;
|
||||||
|
this.logger.log(`AI config updated to v${merged.version}`);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetConfig(): AiConfig {
|
||||||
|
this.backup();
|
||||||
|
const fresh = JSON.parse(JSON.stringify(DEFAULT_AI_CONFIG)) as AiConfig;
|
||||||
|
this.writeFile(fresh);
|
||||||
|
this.cached = fresh;
|
||||||
|
this.logger.log('AI config reset to defaults');
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update a single actor's prompt template, preserving the audit
|
||||||
|
// trail. Used by the wizard's edit slideout. Validates the actor
|
||||||
|
// key so a typo from a hand-crafted PUT can't write garbage.
|
||||||
|
updatePrompt(actor: AiActorKey, template: string, editedBy: string | null): AiConfig {
|
||||||
|
if (!AI_ACTOR_KEYS.includes(actor)) {
|
||||||
|
throw new Error(`Unknown AI actor: ${actor}`);
|
||||||
|
}
|
||||||
|
const current = this.getConfig();
|
||||||
|
const existing = current.prompts[actor] ?? DEFAULT_AI_PROMPTS[actor];
|
||||||
|
const updatedPrompt: AiPromptConfig = {
|
||||||
|
...existing,
|
||||||
|
template,
|
||||||
|
lastEditedAt: new Date().toISOString(),
|
||||||
|
lastEditedBy: editedBy,
|
||||||
|
};
|
||||||
|
const merged: AiConfig = {
|
||||||
|
...current,
|
||||||
|
prompts: { ...current.prompts, [actor]: updatedPrompt },
|
||||||
|
version: (current.version ?? 0) + 1,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.backup();
|
||||||
|
this.writeFile(merged);
|
||||||
|
this.cached = merged;
|
||||||
|
this.logger.log(`AI prompt for actor '${actor}' updated to v${merged.version}`);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore a single actor's prompt back to the SDK-shipped default.
|
||||||
|
// Clears the audit fields so it looks "fresh" in the UI.
|
||||||
|
resetPrompt(actor: AiActorKey): AiConfig {
|
||||||
|
if (!AI_ACTOR_KEYS.includes(actor)) {
|
||||||
|
throw new Error(`Unknown AI actor: ${actor}`);
|
||||||
|
}
|
||||||
|
const current = this.getConfig();
|
||||||
|
const fresh: AiPromptConfig = {
|
||||||
|
...DEFAULT_AI_PROMPTS[actor],
|
||||||
|
lastEditedAt: null,
|
||||||
|
lastEditedBy: null,
|
||||||
|
};
|
||||||
|
const merged: AiConfig = {
|
||||||
|
...current,
|
||||||
|
prompts: { ...current.prompts, [actor]: fresh },
|
||||||
|
version: (current.version ?? 0) + 1,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.backup();
|
||||||
|
this.writeFile(merged);
|
||||||
|
this.cached = merged;
|
||||||
|
this.logger.log(`AI prompt for actor '${actor}' reset to default`);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render a prompt with `{{variable}}` substitution. Variables not
|
||||||
|
// present in `vars` are left as-is so a missing fill is loud
|
||||||
|
// (the AI sees `{{leadName}}` literally) rather than silently
|
||||||
|
// dropping the placeholder. Falls back to DEFAULT_AI_PROMPTS if
|
||||||
|
// the actor key is missing from the loaded config (handles old
|
||||||
|
// ai.json files that predate this refactor).
|
||||||
|
renderPrompt(actor: AiActorKey, vars: Record<string, string | number | null | undefined>): string {
|
||||||
|
const cfg = this.getConfig();
|
||||||
|
const prompt = cfg.prompts?.[actor] ?? DEFAULT_AI_PROMPTS[actor];
|
||||||
|
const template = prompt.template;
|
||||||
|
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
||||||
|
const value = vars[key];
|
||||||
|
if (value === undefined || value === null) return match;
|
||||||
|
return String(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureReady(): AiConfig {
|
||||||
|
if (existsSync(CONFIG_PATH)) {
|
||||||
|
return this.load();
|
||||||
|
}
|
||||||
|
const seeded: AiConfig = JSON.parse(JSON.stringify(DEFAULT_AI_CONFIG)) as AiConfig;
|
||||||
|
let appliedCount = 0;
|
||||||
|
for (const seed of AI_ENV_SEEDS) {
|
||||||
|
const value = process.env[seed.env];
|
||||||
|
if (value === undefined || value === '') continue;
|
||||||
|
(seeded as any)[seed.field] = value;
|
||||||
|
appliedCount += 1;
|
||||||
|
}
|
||||||
|
seeded.version = 1;
|
||||||
|
seeded.updatedAt = new Date().toISOString();
|
||||||
|
this.writeFile(seeded);
|
||||||
|
this.cached = seeded;
|
||||||
|
this.logger.log(
|
||||||
|
`AI config seeded from env (${appliedCount} env var${appliedCount === 1 ? '' : 's'} applied)`,
|
||||||
|
);
|
||||||
|
return seeded;
|
||||||
|
}
|
||||||
|
|
||||||
|
private load(): AiConfig {
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(CONFIG_PATH, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
// Merge incoming prompts against defaults so old ai.json
|
||||||
|
// files (written before the prompts refactor) get topped
|
||||||
|
// up with the new actor entries instead of crashing on
|
||||||
|
// first read. Per-actor merging keeps any admin edits
|
||||||
|
// intact while filling in missing actors.
|
||||||
|
const mergedPrompts: Record<AiActorKey, AiPromptConfig> = { ...DEFAULT_AI_PROMPTS };
|
||||||
|
if (parsed.prompts && typeof parsed.prompts === 'object') {
|
||||||
|
for (const key of AI_ACTOR_KEYS) {
|
||||||
|
const incoming = parsed.prompts[key];
|
||||||
|
if (incoming && typeof incoming === 'object') {
|
||||||
|
mergedPrompts[key] = {
|
||||||
|
...DEFAULT_AI_PROMPTS[key],
|
||||||
|
...incoming,
|
||||||
|
// Always pull `defaultTemplate` from the
|
||||||
|
// shipped defaults — never trust the
|
||||||
|
// file's copy, since the SDK baseline can
|
||||||
|
// change between releases and we want
|
||||||
|
// "reset to default" to always reset to
|
||||||
|
// the latest baseline.
|
||||||
|
defaultTemplate: DEFAULT_AI_PROMPTS[key].defaultTemplate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const merged: AiConfig = {
|
||||||
|
...DEFAULT_AI_CONFIG,
|
||||||
|
...parsed,
|
||||||
|
provider: (parsed.provider ?? DEFAULT_AI_CONFIG.provider) as AiProvider,
|
||||||
|
prompts: mergedPrompts,
|
||||||
|
};
|
||||||
|
this.cached = merged;
|
||||||
|
this.logger.log('AI config loaded from file');
|
||||||
|
return merged;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to load AI config, using defaults: ${err}`);
|
||||||
|
const fresh = JSON.parse(JSON.stringify(DEFAULT_AI_CONFIG)) as AiConfig;
|
||||||
|
this.cached = fresh;
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeFile(cfg: AiConfig) {
|
||||||
|
const dir = dirname(CONFIG_PATH);
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
private backup() {
|
||||||
|
try {
|
||||||
|
if (!existsSync(CONFIG_PATH)) return;
|
||||||
|
if (!existsSync(BACKUP_DIR)) mkdirSync(BACKUP_DIR, { recursive: true });
|
||||||
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
copyFileSync(CONFIG_PATH, join(BACKUP_DIR, `ai-${ts}.json`));
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`AI config backup failed: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
291
src/config/ai.defaults.ts
Normal file
291
src/config/ai.defaults.ts
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
// Admin-editable AI assistant config. Holds the user-facing knobs (provider,
|
||||||
|
// model, temperature) AND a per-actor system prompt template map. API keys
|
||||||
|
// themselves stay in env vars because they are true secrets and rotation is
|
||||||
|
// an ops event.
|
||||||
|
//
|
||||||
|
// Each "actor" is a distinct AI persona used by the sidecar — widget chat,
|
||||||
|
// CC agent helper, supervisor, lead enrichment, etc. Pulling these out of
|
||||||
|
// hardcoded service files lets the hospital admin tune tone, boundaries,
|
||||||
|
// and instructions per persona without a sidecar redeploy. The 7 actors
|
||||||
|
// listed below cover every customer-facing AI surface in Helix Engage as
|
||||||
|
// of 2026-04-08; internal/dev-only prompts (rules engine config helper,
|
||||||
|
// recording speaker-channel identification) stay hardcoded since they are
|
||||||
|
// not customer-tunable.
|
||||||
|
//
|
||||||
|
// Templating: each actor's prompt is a string with `{{variable}}` placeholders
|
||||||
|
// that the calling service fills in via AiConfigService.renderPrompt(actor,
|
||||||
|
// vars). The variable shape per actor is documented in the `variables` field
|
||||||
|
// so the wizard UI can show admins what they can reference.
|
||||||
|
|
||||||
|
export type AiProvider = 'openai' | 'anthropic';
|
||||||
|
|
||||||
|
// Stable keys for each configurable persona. Adding a new actor:
|
||||||
|
// 1. add a key here
|
||||||
|
// 2. add a default entry in DEFAULT_AI_PROMPTS below
|
||||||
|
// 3. add the corresponding renderPrompt call in the consuming service
|
||||||
|
export const AI_ACTOR_KEYS = [
|
||||||
|
'widgetChat',
|
||||||
|
'ccAgentHelper',
|
||||||
|
'supervisorChat',
|
||||||
|
'leadEnrichment',
|
||||||
|
'callInsight',
|
||||||
|
'callAssist',
|
||||||
|
'recordingAnalysis',
|
||||||
|
] as const;
|
||||||
|
export type AiActorKey = (typeof AI_ACTOR_KEYS)[number];
|
||||||
|
|
||||||
|
export type AiPromptConfig = {
|
||||||
|
// Human-readable name shown in the wizard UI.
|
||||||
|
label: string;
|
||||||
|
// One-line description of when this persona is invoked.
|
||||||
|
description: string;
|
||||||
|
// Variables the template can reference, with a one-line hint each.
|
||||||
|
// Surfaced in the edit slideout so admins know what `{{var}}` they
|
||||||
|
// can use without reading code.
|
||||||
|
variables: Array<{ key: string; description: string }>;
|
||||||
|
// The current template (may be admin-edited).
|
||||||
|
template: string;
|
||||||
|
// The original baseline so we can offer a "reset to default" button.
|
||||||
|
defaultTemplate: string;
|
||||||
|
// Audit fields — when this prompt was last edited and by whom.
|
||||||
|
// null on the default-supplied entries.
|
||||||
|
lastEditedAt: string | null;
|
||||||
|
lastEditedBy: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiConfig = {
|
||||||
|
provider: AiProvider;
|
||||||
|
model: string;
|
||||||
|
// 0..2, controls randomness. Default 0.7 matches the existing hardcoded
|
||||||
|
// values used in WidgetChatService and AI tools.
|
||||||
|
temperature: number;
|
||||||
|
// Per-actor system prompt templates. Keyed by AiActorKey so callers can
|
||||||
|
// do `config.prompts.widgetChat.template` and missing keys are caught
|
||||||
|
// at compile time.
|
||||||
|
prompts: Record<AiActorKey, AiPromptConfig>;
|
||||||
|
version?: number;
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Default templates — extracted verbatim from the hardcoded versions in:
|
||||||
|
// - widget-chat.service.ts → widgetChat
|
||||||
|
// - ai-chat.controller.ts → ccAgentHelper, supervisorChat
|
||||||
|
// - ai-enrichment.service.ts → leadEnrichment
|
||||||
|
// - ai-insight.consumer.ts → callInsight
|
||||||
|
// - call-assist.service.ts → callAssist
|
||||||
|
// - recordings.service.ts → recordingAnalysis
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const WIDGET_CHAT_DEFAULT = `You are a helpful, concise assistant for {{hospitalName}}.
|
||||||
|
You are chatting with a website visitor named {{userName}}.
|
||||||
|
|
||||||
|
{{branchContext}}
|
||||||
|
|
||||||
|
TOOL USAGE RULES (STRICT):
|
||||||
|
- When the user asks about departments, call list_departments and DO NOT also list departments in prose.
|
||||||
|
- When they ask about clinic timings, visiting hours, or "when is X open", call show_clinic_timings.
|
||||||
|
- When they ask about doctors in a department, call show_doctors and DO NOT also list doctors in prose.
|
||||||
|
- When they ask about a specific doctor's availability or want to book with them, call show_doctor_slots.
|
||||||
|
- When the conversation is trending toward booking, call suggest_booking.
|
||||||
|
- After calling a tool, DO NOT restate its contents in prose. At most write a single short sentence
|
||||||
|
(under 15 words) framing the widget, or no text at all. The widget already shows the data.
|
||||||
|
- If you are about to write a bulleted or numbered list of departments, doctors, hours, or slots,
|
||||||
|
STOP and call the appropriate tool instead.
|
||||||
|
- NEVER use markdown formatting (no **bold**, no *italic*, no bullet syntax). Plain text only in
|
||||||
|
non-tool replies.
|
||||||
|
- NEVER invent or mention specific dates in prose or tool inputs. The server owns "today".
|
||||||
|
If the visitor asks about a future date, tell them to use the Book tab's date picker.
|
||||||
|
|
||||||
|
OTHER RULES:
|
||||||
|
- Answer other questions (directions, general info) concisely in prose.
|
||||||
|
- If you do not know something, say so and suggest they call the hospital.
|
||||||
|
- Never quote prices. No medical advice. For clinical questions, defer to a doctor.
|
||||||
|
|
||||||
|
{{knowledgeBase}}`;
|
||||||
|
|
||||||
|
const CC_AGENT_HELPER_DEFAULT = `You are an AI assistant for call center agents at {{hospitalName}}.
|
||||||
|
You help agents answer questions about patients, doctors, appointments, clinics, and hospital services during live calls.
|
||||||
|
|
||||||
|
IMPORTANT — ANSWER FROM KNOWLEDGE BASE FIRST:
|
||||||
|
The knowledge base below contains REAL clinic locations, timings, doctor details, health packages, and insurance partners.
|
||||||
|
When asked about clinic timings, locations, doctor availability, packages, or insurance — ALWAYS check the knowledge base FIRST before saying you don't know.
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
1. For patient-specific questions (history, appointments, calls), use the lookup tools. NEVER guess patient data. NEVER say a patient doesn't exist without calling a tool first.
|
||||||
|
2. When CURRENT CONTEXT lists a Lead ID, the lookup tools already know which caller to pull. Call them with NO arguments — do not re-type the Lead ID or Patient ID as a tool argument:
|
||||||
|
- lookup_call_history() → calls for this caller
|
||||||
|
- lookup_lead_activities() → activity log for this caller
|
||||||
|
- lookup_appointments() → appointments for this caller
|
||||||
|
Pass IDs explicitly only when the agent is asking about a different, specific patient — and even then, prefer name/phone via lookup_patient.
|
||||||
|
3. For "summarize this patient's history" or similar, chain multiple lookups (call history + lead activities + appointments) and stitch the answer from what came back. If all three return empty, say so honestly — otherwise report what you found.
|
||||||
|
4. For doctor details beyond what's in the KB, use the lookup_doctor tool.
|
||||||
|
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.
|
||||||
|
|
||||||
|
KNOWLEDGE BASE (this is real data from our system):
|
||||||
|
{{knowledgeBase}}`;
|
||||||
|
|
||||||
|
const SUPERVISOR_CHAT_DEFAULT = `You are an AI assistant for supervisors at {{hospitalName}}'s call center (Helix Engage).
|
||||||
|
You help supervisors monitor team performance, identify issues, and make data-driven decisions.
|
||||||
|
|
||||||
|
## YOUR CAPABILITIES
|
||||||
|
You have access to tools that query real-time data:
|
||||||
|
- **Agent performance**: call counts, conversion rates, NPS scores, idle time, pending follow-ups
|
||||||
|
- **Campaign stats**: lead counts, conversion rates per campaign, platform breakdown
|
||||||
|
- **Call summary**: total calls, inbound/outbound split, missed call rate, disposition breakdown
|
||||||
|
- **SLA breaches**: missed calls that haven't been called back within the SLA threshold
|
||||||
|
|
||||||
|
## RULES
|
||||||
|
1. ALWAYS use tools to fetch data before answering. NEVER guess or fabricate performance numbers.
|
||||||
|
2. Be specific — include actual numbers from the tool response, not vague qualifiers.
|
||||||
|
3. When comparing agents, use their configured thresholds (minConversionPercent, minNpsThreshold, maxIdleMinutes) and team averages. Let the data determine who is underperforming — do not assume.
|
||||||
|
4. Be concise — supervisors want quick answers. Use bullet points.
|
||||||
|
5. When recommending actions, ground them in the data returned by tools.
|
||||||
|
6. If asked about trends, use the call summary tool with different periods.
|
||||||
|
7. Do not use any agent name in a negative context unless the data explicitly supports it.`;
|
||||||
|
|
||||||
|
const LEAD_ENRICHMENT_DEFAULT = `You are an AI assistant for a hospital call center.
|
||||||
|
An inbound call is coming in from a lead. Summarize their history and suggest what the call center agent should do.
|
||||||
|
|
||||||
|
Lead details:
|
||||||
|
- Name: {{leadName}}
|
||||||
|
- Source: {{leadSource}}
|
||||||
|
- Interested in: {{interestedService}}
|
||||||
|
- Current status: {{leadStatus}}
|
||||||
|
- Lead age: {{daysSince}} days
|
||||||
|
- Contact attempts: {{contactAttempts}}
|
||||||
|
|
||||||
|
Recent activity:
|
||||||
|
{{activities}}`;
|
||||||
|
|
||||||
|
const CALL_INSIGHT_DEFAULT = `You are a CRM assistant for {{hospitalName}}.
|
||||||
|
Generate a brief, actionable insight about this lead based on their interaction history.
|
||||||
|
Be specific — reference actual dates, dispositions, and patterns.
|
||||||
|
If the lead has booked appointments, mention upcoming ones.
|
||||||
|
If they keep calling about the same thing, note the pattern.`;
|
||||||
|
|
||||||
|
const CALL_ASSIST_DEFAULT = `You are a real-time call assistant for {{hospitalName}}.
|
||||||
|
You listen to the customer's words and provide brief, actionable suggestions for the CC agent.
|
||||||
|
|
||||||
|
{{context}}
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- Keep suggestions under 2 sentences
|
||||||
|
- Focus on actionable next steps the agent should take NOW
|
||||||
|
- If customer mentions a doctor or department, suggest available slots
|
||||||
|
- If customer wants to cancel or reschedule, note relevant appointment details
|
||||||
|
- If customer sounds upset, suggest empathetic response
|
||||||
|
- Do NOT repeat what the agent already knows`;
|
||||||
|
|
||||||
|
const RECORDING_ANALYSIS_DEFAULT = `You are a call quality analyst for {{hospitalName}}.
|
||||||
|
Analyze the following call recording transcript and provide structured insights.
|
||||||
|
Be specific, brief, and actionable. Focus on healthcare context.
|
||||||
|
{{summaryBlock}}
|
||||||
|
{{topicsBlock}}`;
|
||||||
|
|
||||||
|
// Helper that builds an AiPromptConfig with the same template for both
|
||||||
|
// `template` and `defaultTemplate` — what every actor starts with on a
|
||||||
|
// fresh boot.
|
||||||
|
const promptDefault = (
|
||||||
|
label: string,
|
||||||
|
description: string,
|
||||||
|
variables: Array<{ key: string; description: string }>,
|
||||||
|
template: string,
|
||||||
|
): AiPromptConfig => ({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
variables,
|
||||||
|
template,
|
||||||
|
defaultTemplate: template,
|
||||||
|
lastEditedAt: null,
|
||||||
|
lastEditedBy: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DEFAULT_AI_PROMPTS: Record<AiActorKey, AiPromptConfig> = {
|
||||||
|
widgetChat: promptDefault(
|
||||||
|
'Website widget chat',
|
||||||
|
'Patient-facing bot embedded on the hospital website. Handles general questions, finds doctors, suggests appointments.',
|
||||||
|
[
|
||||||
|
{ key: 'hospitalName', description: 'Branded hospital display name from theme.json' },
|
||||||
|
{ key: 'userName', description: 'Visitor first name (or "there" if unknown)' },
|
||||||
|
{ key: 'branchContext', description: 'Pre-rendered branch-selection instructions block' },
|
||||||
|
{ key: 'knowledgeBase', description: 'Pre-rendered list of departments + doctors + clinics' },
|
||||||
|
],
|
||||||
|
WIDGET_CHAT_DEFAULT,
|
||||||
|
),
|
||||||
|
ccAgentHelper: promptDefault(
|
||||||
|
'CC agent helper',
|
||||||
|
'In-call assistant the CC agent uses to look up patient history, doctor details, and clinic info while on a call.',
|
||||||
|
[
|
||||||
|
{ key: 'hospitalName', description: 'Branded hospital display name' },
|
||||||
|
{ key: 'knowledgeBase', description: 'Pre-rendered hospital knowledge base (clinics, doctors, packages)' },
|
||||||
|
],
|
||||||
|
CC_AGENT_HELPER_DEFAULT,
|
||||||
|
),
|
||||||
|
supervisorChat: promptDefault(
|
||||||
|
'Supervisor assistant',
|
||||||
|
'AI tools the supervisor uses to query agent performance, campaign stats, and SLA breaches.',
|
||||||
|
[
|
||||||
|
{ key: 'hospitalName', description: 'Branded hospital display name' },
|
||||||
|
],
|
||||||
|
SUPERVISOR_CHAT_DEFAULT,
|
||||||
|
),
|
||||||
|
leadEnrichment: promptDefault(
|
||||||
|
'Lead enrichment',
|
||||||
|
'Generates an AI summary + suggested action for a new inbound lead before the agent picks up.',
|
||||||
|
[
|
||||||
|
{ key: 'leadName', description: 'Lead first + last name' },
|
||||||
|
{ key: 'leadSource', description: 'Source channel (WHATSAPP, GOOGLE_ADS, etc.)' },
|
||||||
|
{ key: 'interestedService', description: 'What the lead enquired about' },
|
||||||
|
{ key: 'leadStatus', description: 'Current lead status' },
|
||||||
|
{ key: 'daysSince', description: 'Days since the lead was created' },
|
||||||
|
{ key: 'contactAttempts', description: 'Prior contact attempts count' },
|
||||||
|
{ key: 'activities', description: 'Pre-rendered recent activity summary' },
|
||||||
|
],
|
||||||
|
LEAD_ENRICHMENT_DEFAULT,
|
||||||
|
),
|
||||||
|
callInsight: promptDefault(
|
||||||
|
'Post-call insight',
|
||||||
|
'After each call, generates a 2-3 sentence summary + a single suggested next action for the lead record.',
|
||||||
|
[
|
||||||
|
{ key: 'hospitalName', description: 'Branded hospital display name' },
|
||||||
|
],
|
||||||
|
CALL_INSIGHT_DEFAULT,
|
||||||
|
),
|
||||||
|
callAssist: promptDefault(
|
||||||
|
'Live call whisper',
|
||||||
|
'Real-time suggestions whispered to the CC agent during a call, based on the running transcript.',
|
||||||
|
[
|
||||||
|
{ key: 'hospitalName', description: 'Branded hospital display name' },
|
||||||
|
{ key: 'context', description: 'Pre-rendered call context (current lead, recent activities, available doctors)' },
|
||||||
|
],
|
||||||
|
CALL_ASSIST_DEFAULT,
|
||||||
|
),
|
||||||
|
recordingAnalysis: promptDefault(
|
||||||
|
'Call recording analysis',
|
||||||
|
'Analyses post-call recording transcripts to extract key topics, action items, coaching notes, and compliance flags.',
|
||||||
|
[
|
||||||
|
{ key: 'hospitalName', description: 'Branded hospital display name' },
|
||||||
|
{ key: 'summaryBlock', description: 'Optional pre-rendered "Call summary: ..." line (empty when none)' },
|
||||||
|
{ key: 'topicsBlock', description: 'Optional pre-rendered "Detected topics: ..." line (empty when none)' },
|
||||||
|
],
|
||||||
|
RECORDING_ANALYSIS_DEFAULT,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_AI_CONFIG: AiConfig = {
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
temperature: 0.7,
|
||||||
|
prompts: DEFAULT_AI_PROMPTS,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Field-by-field mapping from the legacy env vars used by ai-provider.ts
|
||||||
|
// (AI_PROVIDER + AI_MODEL). API keys are NOT seeded — they remain in env.
|
||||||
|
export const AI_ENV_SEEDS: Array<{ env: string; field: keyof Pick<AiConfig, 'provider' | 'model'> }> = [
|
||||||
|
{ env: 'AI_PROVIDER', field: 'provider' },
|
||||||
|
{ env: 'AI_MODEL', field: 'model' },
|
||||||
|
];
|
||||||
@@ -1,10 +1,54 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
import { ThemeController } from './theme.controller';
|
import { ThemeController } from './theme.controller';
|
||||||
import { ThemeService } from './theme.service';
|
import { ThemeService } from './theme.service';
|
||||||
|
import { WidgetKeysService } from './widget-keys.service';
|
||||||
|
import { WidgetConfigService } from './widget-config.service';
|
||||||
|
import { WidgetConfigController } from './widget-config.controller';
|
||||||
|
import { SetupStateService } from './setup-state.service';
|
||||||
|
import { SetupStateController } from './setup-state.controller';
|
||||||
|
import { TelephonyConfigService } from './telephony-config.service';
|
||||||
|
import { TelephonyConfigController } from './telephony-config.controller';
|
||||||
|
import { AiConfigService } from './ai-config.service';
|
||||||
|
import { AiConfigController } from './ai-config.controller';
|
||||||
|
|
||||||
|
// Central config module — owns everything in data/*.json that's editable
|
||||||
|
// from the admin portal. Today: theme, widget, setup-state, telephony, ai.
|
||||||
|
//
|
||||||
|
// Marked @Global() so the 3 new sidecar config services (setup-state, telephony,
|
||||||
|
// ai) are injectable from any module without explicit import wiring. Without this,
|
||||||
|
// AuthModule + OzonetelAgentModule + MaintModule would all need to import
|
||||||
|
// ConfigThemeModule, which would create a circular dependency with AuthModule
|
||||||
|
// (ConfigThemeModule already imports AuthModule for SessionService).
|
||||||
|
//
|
||||||
|
// AuthModule is imported because WidgetKeysService depends on SessionService
|
||||||
|
// (Redis-backed cache for widget site key storage).
|
||||||
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [ThemeController],
|
imports: [AuthModule, PlatformModule],
|
||||||
providers: [ThemeService],
|
controllers: [
|
||||||
exports: [ThemeService],
|
ThemeController,
|
||||||
|
WidgetConfigController,
|
||||||
|
SetupStateController,
|
||||||
|
TelephonyConfigController,
|
||||||
|
AiConfigController,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
ThemeService,
|
||||||
|
WidgetKeysService,
|
||||||
|
WidgetConfigService,
|
||||||
|
SetupStateService,
|
||||||
|
TelephonyConfigService,
|
||||||
|
AiConfigService,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
ThemeService,
|
||||||
|
WidgetKeysService,
|
||||||
|
WidgetConfigService,
|
||||||
|
SetupStateService,
|
||||||
|
TelephonyConfigService,
|
||||||
|
AiConfigService,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class ConfigThemeModule {}
|
export class ConfigThemeModule {}
|
||||||
|
|||||||
@@ -22,6 +22,16 @@ export default () => ({
|
|||||||
missedQueue: {
|
missedQueue: {
|
||||||
pollIntervalMs: parseInt(process.env.MISSED_QUEUE_POLL_INTERVAL_MS ?? '30000', 10),
|
pollIntervalMs: parseInt(process.env.MISSED_QUEUE_POLL_INTERVAL_MS ?? '30000', 10),
|
||||||
},
|
},
|
||||||
|
worklist: {
|
||||||
|
// Per-page fetch size from the platform GraphQL endpoint. Tuned to
|
||||||
|
// balance response size vs. page count. Platform's Relay pagination
|
||||||
|
// typically caps at 100–200 per page.
|
||||||
|
pageSize: parseInt(process.env.WORKLIST_PAGE_SIZE ?? '50', 10),
|
||||||
|
// Hard ceiling on pages fetched per poll. Safety valve against
|
||||||
|
// unbounded cost when a tenant has thousands of pending callbacks.
|
||||||
|
// maxPages * pageSize = effective worklist size.
|
||||||
|
maxPages: parseInt(process.env.WORKLIST_MAX_PAGES ?? '10', 10),
|
||||||
|
},
|
||||||
ai: {
|
ai: {
|
||||||
provider: process.env.AI_PROVIDER ?? 'openai',
|
provider: process.env.AI_PROVIDER ?? 'openai',
|
||||||
anthropicApiKey: process.env.ANTHROPIC_API_KEY ?? '',
|
anthropicApiKey: process.env.ANTHROPIC_API_KEY ?? '',
|
||||||
|
|||||||
72
src/config/setup-state.controller.ts
Normal file
72
src/config/setup-state.controller.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { Body, Controller, Get, Logger, Param, Post, Put } from '@nestjs/common';
|
||||||
|
import { SetupStateService } from './setup-state.service';
|
||||||
|
import type { SetupStepName } from './setup-state.defaults';
|
||||||
|
|
||||||
|
// Public endpoint family for the onboarding wizard. Mounted under /api/config
|
||||||
|
// alongside theme/widget. No auth guard yet — matches existing convention with
|
||||||
|
// ThemeController. To be tightened when the staff portal admin auth is in place.
|
||||||
|
//
|
||||||
|
// GET /api/config/setup-state full state + isWizardRequired
|
||||||
|
// PUT /api/config/setup-state/steps/:step { completed: bool, completedBy?: string }
|
||||||
|
// POST /api/config/setup-state/dismiss dismiss the wizard for this workspace
|
||||||
|
// POST /api/config/setup-state/reset reset all steps to incomplete (admin)
|
||||||
|
@Controller('api/config')
|
||||||
|
export class SetupStateController {
|
||||||
|
private readonly logger = new Logger(SetupStateController.name);
|
||||||
|
|
||||||
|
constructor(private readonly setupState: SetupStateService) {}
|
||||||
|
|
||||||
|
@Get('setup-state')
|
||||||
|
async getState() {
|
||||||
|
// Use the checked variant so the platform workspace probe runs
|
||||||
|
// before we serialize. Catches workspace changes (DB resets,
|
||||||
|
// re-onboards) on the very first frontend GET.
|
||||||
|
const state = await this.setupState.getStateChecked();
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
wizardRequired: this.setupState.isWizardRequired(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('setup-state/steps/:step')
|
||||||
|
updateStep(
|
||||||
|
@Param('step') step: SetupStepName,
|
||||||
|
@Body() body: { completed: boolean; completedBy?: string },
|
||||||
|
) {
|
||||||
|
const updated = body.completed
|
||||||
|
? this.setupState.markStepCompleted(step, body.completedBy ?? null)
|
||||||
|
: this.setupState.markStepIncomplete(step);
|
||||||
|
// Mirror GET shape — include `wizardRequired` so the frontend
|
||||||
|
// doesn't see a state object missing the field and re-render
|
||||||
|
// into an inconsistent shape.
|
||||||
|
return { ...updated, wizardRequired: this.setupState.isWizardRequired() };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('setup-state/dismiss')
|
||||||
|
dismiss() {
|
||||||
|
const updated = this.setupState.dismissWizard();
|
||||||
|
return { ...updated, wizardRequired: this.setupState.isWizardRequired() };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('setup-state/reset')
|
||||||
|
reset() {
|
||||||
|
const updated = this.setupState.resetState();
|
||||||
|
return { ...updated, wizardRequired: this.setupState.isWizardRequired() };
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI-level flags the frontend reads at app boot to tailor which admin
|
||||||
|
// surfaces are available. Driven by sidecar env vars so each workspace
|
||||||
|
// can be configured independently without touching the frontend build.
|
||||||
|
//
|
||||||
|
// setupManaged=true means "the product team handles setup for this
|
||||||
|
// workspace" — hide the Settings nav, routes, and the resume-setup
|
||||||
|
// banner. The wizard + setup-state APIs stay functional for ops use
|
||||||
|
// (a support engineer can still PUT /steps/:step or hit the routes
|
||||||
|
// directly); only the end-user admin UI is hidden.
|
||||||
|
@Get('ui-flags')
|
||||||
|
uiFlags() {
|
||||||
|
return {
|
||||||
|
setupManaged: process.env.HELIX_SETUP_MANAGED === 'true',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/config/setup-state.defaults.ts
Normal file
60
src/config/setup-state.defaults.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// Tracks completion of the 6 onboarding setup steps the hospital admin walks
|
||||||
|
// through after first login. Drives the wizard auto-show on /setup and the
|
||||||
|
// completion badges on the Settings hub.
|
||||||
|
|
||||||
|
export type SetupStepName =
|
||||||
|
| 'identity'
|
||||||
|
| 'clinics'
|
||||||
|
| 'doctors'
|
||||||
|
| 'team'
|
||||||
|
| 'telephony'
|
||||||
|
| 'ai';
|
||||||
|
|
||||||
|
export type SetupStepStatus = {
|
||||||
|
completed: boolean;
|
||||||
|
completedAt: string | null;
|
||||||
|
completedBy: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SetupState = {
|
||||||
|
version?: number;
|
||||||
|
updatedAt?: string;
|
||||||
|
// When true the wizard never auto-shows even if some steps are incomplete.
|
||||||
|
// Settings hub still shows the per-section badges.
|
||||||
|
wizardDismissed: boolean;
|
||||||
|
steps: Record<SetupStepName, SetupStepStatus>;
|
||||||
|
// The platform workspace this state belongs to. The sidecar's API key
|
||||||
|
// is scoped to exactly one workspace, so on every load we compare the
|
||||||
|
// file's workspaceId against the live currentWorkspace.id and reset
|
||||||
|
// the file if they differ. Stops setup-state from leaking across DB
|
||||||
|
// resets and re-onboards.
|
||||||
|
workspaceId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyStep = (): SetupStepStatus => ({
|
||||||
|
completed: false,
|
||||||
|
completedAt: null,
|
||||||
|
completedBy: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SETUP_STEP_NAMES: readonly SetupStepName[] = [
|
||||||
|
'identity',
|
||||||
|
'clinics',
|
||||||
|
'doctors',
|
||||||
|
'team',
|
||||||
|
'telephony',
|
||||||
|
'ai',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const DEFAULT_SETUP_STATE: SetupState = {
|
||||||
|
wizardDismissed: false,
|
||||||
|
workspaceId: null,
|
||||||
|
steps: {
|
||||||
|
identity: emptyStep(),
|
||||||
|
clinics: emptyStep(),
|
||||||
|
doctors: emptyStep(),
|
||||||
|
team: emptyStep(),
|
||||||
|
telephony: emptyStep(),
|
||||||
|
ai: emptyStep(),
|
||||||
|
},
|
||||||
|
};
|
||||||
220
src/config/setup-state.service.ts
Normal file
220
src/config/setup-state.service.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import {
|
||||||
|
DEFAULT_SETUP_STATE,
|
||||||
|
SETUP_STEP_NAMES,
|
||||||
|
type SetupState,
|
||||||
|
type SetupStepName,
|
||||||
|
} from './setup-state.defaults';
|
||||||
|
|
||||||
|
const SETUP_STATE_PATH = join(process.cwd(), 'data', 'setup-state.json');
|
||||||
|
|
||||||
|
// File-backed store for the onboarding wizard's progress. Mirrors the
|
||||||
|
// pattern of ThemeService and WidgetConfigService — load on init, cache in
|
||||||
|
// memory, write on every change. No backups (the data is small and easily
|
||||||
|
// recreated by the wizard if it ever gets corrupted).
|
||||||
|
//
|
||||||
|
// Workspace scoping: the sidecar's API key is scoped to exactly one
|
||||||
|
// workspace, so on first access we compare the file's stored workspaceId
|
||||||
|
// against the live currentWorkspace.id from the platform. If they differ
|
||||||
|
// (DB reset, re-onboard, sidecar pointed at a new workspace), the file is
|
||||||
|
// reset before any reads return. This guarantees a fresh wizard for a
|
||||||
|
// fresh workspace without manual file deletion.
|
||||||
|
@Injectable()
|
||||||
|
export class SetupStateService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(SetupStateService.name);
|
||||||
|
private cached: SetupState | null = null;
|
||||||
|
// Memoize the platform's currentWorkspace.id lookup so we don't hit
|
||||||
|
// the platform on every getState() call. Set once per process boot
|
||||||
|
// (or after a successful reset).
|
||||||
|
private liveWorkspaceId: string | null = null;
|
||||||
|
private workspaceCheckPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
constructor(private platform: PlatformGraphqlService) {}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
this.load();
|
||||||
|
// Fire-and-forget the workspace probe so the file gets aligned
|
||||||
|
// before the frontend's first GET. Errors are logged but
|
||||||
|
// non-fatal — if the platform is down at boot, the legacy
|
||||||
|
// unscoped behaviour kicks in until the first reachable probe.
|
||||||
|
this.ensureWorkspaceMatch().catch((err) =>
|
||||||
|
this.logger.warn(`Initial workspace probe failed: ${err}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getState(): SetupState {
|
||||||
|
if (this.cached) return this.cached;
|
||||||
|
return this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Awaits a workspace check before returning state. The controller
|
||||||
|
// calls this so the GET response always reflects the current
|
||||||
|
// workspace, not yesterday's.
|
||||||
|
async getStateChecked(): Promise<SetupState> {
|
||||||
|
await this.ensureWorkspaceMatch();
|
||||||
|
return this.getState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureWorkspaceMatch(): Promise<void> {
|
||||||
|
// Single-flight: if a check is already running, await it.
|
||||||
|
if (this.workspaceCheckPromise) return this.workspaceCheckPromise;
|
||||||
|
if (this.liveWorkspaceId) {
|
||||||
|
// Already validated this process. Trust the cache.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.workspaceCheckPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const data = await this.platform.query<{
|
||||||
|
currentWorkspace: { id: string };
|
||||||
|
}>(`{ currentWorkspace { id } }`);
|
||||||
|
const liveId = data?.currentWorkspace?.id ?? null;
|
||||||
|
if (!liveId) {
|
||||||
|
this.logger.warn(
|
||||||
|
'currentWorkspace.id was empty — cannot scope setup-state',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.liveWorkspaceId = liveId;
|
||||||
|
const current = this.getState();
|
||||||
|
if (current.workspaceId && current.workspaceId !== liveId) {
|
||||||
|
this.logger.log(
|
||||||
|
`Workspace changed (${current.workspaceId} → ${liveId}) — resetting setup-state`,
|
||||||
|
);
|
||||||
|
this.resetState();
|
||||||
|
}
|
||||||
|
if (!current.workspaceId) {
|
||||||
|
// First boot after the workspaceId field was added
|
||||||
|
// (or first boot ever). Stamp the file so future
|
||||||
|
// boots can detect drift.
|
||||||
|
const stamped: SetupState = {
|
||||||
|
...this.getState(),
|
||||||
|
workspaceId: liveId,
|
||||||
|
};
|
||||||
|
this.writeFile(stamped);
|
||||||
|
this.cached = stamped;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.workspaceCheckPromise = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return this.workspaceCheckPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if any required step is incomplete and the wizard hasn't
|
||||||
|
// been explicitly dismissed. Used by the frontend post-login redirect.
|
||||||
|
isWizardRequired(): boolean {
|
||||||
|
const s = this.getState();
|
||||||
|
if (s.wizardDismissed) return false;
|
||||||
|
return SETUP_STEP_NAMES.some(name => !s.steps[name].completed);
|
||||||
|
}
|
||||||
|
|
||||||
|
markStepCompleted(step: SetupStepName, completedBy: string | null = null): SetupState {
|
||||||
|
const current = this.getState();
|
||||||
|
if (!current.steps[step]) {
|
||||||
|
throw new Error(`Unknown setup step: ${step}`);
|
||||||
|
}
|
||||||
|
const updated: SetupState = {
|
||||||
|
...current,
|
||||||
|
steps: {
|
||||||
|
...current.steps,
|
||||||
|
[step]: {
|
||||||
|
completed: true,
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
completedBy,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
version: (current.version ?? 0) + 1,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.writeFile(updated);
|
||||||
|
this.cached = updated;
|
||||||
|
this.logger.log(`Setup step '${step}' marked completed`);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
markStepIncomplete(step: SetupStepName): SetupState {
|
||||||
|
const current = this.getState();
|
||||||
|
if (!current.steps[step]) {
|
||||||
|
throw new Error(`Unknown setup step: ${step}`);
|
||||||
|
}
|
||||||
|
const updated: SetupState = {
|
||||||
|
...current,
|
||||||
|
steps: {
|
||||||
|
...current.steps,
|
||||||
|
[step]: { completed: false, completedAt: null, completedBy: null },
|
||||||
|
},
|
||||||
|
version: (current.version ?? 0) + 1,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.writeFile(updated);
|
||||||
|
this.cached = updated;
|
||||||
|
this.logger.log(`Setup step '${step}' marked incomplete`);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
dismissWizard(): SetupState {
|
||||||
|
const current = this.getState();
|
||||||
|
const updated: SetupState = {
|
||||||
|
...current,
|
||||||
|
wizardDismissed: true,
|
||||||
|
version: (current.version ?? 0) + 1,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.writeFile(updated);
|
||||||
|
this.cached = updated;
|
||||||
|
this.logger.log('Setup wizard dismissed');
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetState(): SetupState {
|
||||||
|
// Preserve the live workspaceId on reset so the file remains
|
||||||
|
// scoped — otherwise the next workspace check would think the
|
||||||
|
// file is unscoped and re-stamp it, which is fine but creates
|
||||||
|
// an extra write.
|
||||||
|
const fresh: SetupState = {
|
||||||
|
...DEFAULT_SETUP_STATE,
|
||||||
|
workspaceId: this.liveWorkspaceId ?? null,
|
||||||
|
};
|
||||||
|
this.writeFile(fresh);
|
||||||
|
this.cached = fresh;
|
||||||
|
this.logger.log('Setup state reset to defaults');
|
||||||
|
return this.cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
private load(): SetupState {
|
||||||
|
try {
|
||||||
|
if (existsSync(SETUP_STATE_PATH)) {
|
||||||
|
const raw = readFileSync(SETUP_STATE_PATH, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
// Defensive merge: if a new step name is added later, the old
|
||||||
|
// file won't have it. Fill missing steps with the empty default.
|
||||||
|
const merged: SetupState = {
|
||||||
|
...DEFAULT_SETUP_STATE,
|
||||||
|
...parsed,
|
||||||
|
steps: {
|
||||||
|
...DEFAULT_SETUP_STATE.steps,
|
||||||
|
...(parsed.steps ?? {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this.cached = merged;
|
||||||
|
this.logger.log('Setup state loaded from file');
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to load setup state: ${err}`);
|
||||||
|
}
|
||||||
|
const fresh: SetupState = JSON.parse(JSON.stringify(DEFAULT_SETUP_STATE));
|
||||||
|
this.cached = fresh;
|
||||||
|
this.logger.log('Using default setup state (no file yet)');
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeFile(state: SetupState) {
|
||||||
|
const dir = dirname(SETUP_STATE_PATH);
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(SETUP_STATE_PATH, JSON.stringify(state, null, 2), 'utf8');
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/config/telephony-config.controller.ts
Normal file
32
src/config/telephony-config.controller.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Body, Controller, Get, Logger, Post, Put } from '@nestjs/common';
|
||||||
|
import { TelephonyConfigService } from './telephony-config.service';
|
||||||
|
import type { TelephonyConfig } from './telephony.defaults';
|
||||||
|
|
||||||
|
// Mounted under /api/config alongside theme/widget/setup-state.
|
||||||
|
//
|
||||||
|
// GET /api/config/telephony — masked (secrets returned as '***masked***')
|
||||||
|
// PUT /api/config/telephony — admin update; '***masked***' is treated as "no change"
|
||||||
|
// POST /api/config/telephony/reset — reset to defaults (admin)
|
||||||
|
@Controller('api/config')
|
||||||
|
export class TelephonyConfigController {
|
||||||
|
private readonly logger = new Logger(TelephonyConfigController.name);
|
||||||
|
|
||||||
|
constructor(private readonly telephony: TelephonyConfigService) {}
|
||||||
|
|
||||||
|
@Get('telephony')
|
||||||
|
getTelephony() {
|
||||||
|
return this.telephony.getMaskedConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('telephony')
|
||||||
|
updateTelephony(@Body() body: Partial<TelephonyConfig>) {
|
||||||
|
this.logger.log('Telephony config update request');
|
||||||
|
return this.telephony.updateConfig(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('telephony/reset')
|
||||||
|
resetTelephony() {
|
||||||
|
this.logger.log('Telephony config reset request');
|
||||||
|
return this.telephony.resetConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
164
src/config/telephony-config.service.ts
Normal file
164
src/config/telephony-config.service.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
import {
|
||||||
|
DEFAULT_TELEPHONY_CONFIG,
|
||||||
|
TELEPHONY_ENV_SEEDS,
|
||||||
|
type TelephonyConfig,
|
||||||
|
} from './telephony.defaults';
|
||||||
|
|
||||||
|
const CONFIG_PATH = join(process.cwd(), 'data', 'telephony.json');
|
||||||
|
const BACKUP_DIR = join(process.cwd(), 'data', 'telephony-backups');
|
||||||
|
|
||||||
|
// File-backed telephony config. Replaces eight env vars (OZONETEL_*, SIP_*,
|
||||||
|
// EXOTEL_*). On first boot we copy whatever those env vars hold into the
|
||||||
|
// config file so existing deployments don't break — after that, the env vars
|
||||||
|
// are no longer read by anything.
|
||||||
|
//
|
||||||
|
// Mirrors WidgetConfigService and ThemeService — load on init, in-memory
|
||||||
|
// cache, file backups on every change.
|
||||||
|
@Injectable()
|
||||||
|
export class TelephonyConfigService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(TelephonyConfigService.name);
|
||||||
|
private cached: TelephonyConfig | null = null;
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
this.ensureReady();
|
||||||
|
}
|
||||||
|
|
||||||
|
getConfig(): TelephonyConfig {
|
||||||
|
if (this.cached) return this.cached;
|
||||||
|
return this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public-facing subset for the GET endpoint — masks the Exotel API token
|
||||||
|
// so it can't be exfiltrated by an unauthenticated reader. The admin UI
|
||||||
|
// gets the full config via getConfig() through the controller's PUT path
|
||||||
|
// (the new value is supplied client-side, the old value is never displayed).
|
||||||
|
getMaskedConfig() {
|
||||||
|
const c = this.getConfig();
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
exotel: {
|
||||||
|
...c.exotel,
|
||||||
|
apiToken: c.exotel.apiToken ? '***masked***' : '',
|
||||||
|
},
|
||||||
|
ozonetel: {
|
||||||
|
...c.ozonetel,
|
||||||
|
agentPassword: c.ozonetel.agentPassword ? '***masked***' : '',
|
||||||
|
adminPassword: c.ozonetel.adminPassword ? '***masked***' : '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
updateConfig(updates: Partial<TelephonyConfig>): TelephonyConfig {
|
||||||
|
const current = this.getConfig();
|
||||||
|
// Deep-ish merge — each top-level group merges its own keys.
|
||||||
|
const merged: TelephonyConfig = {
|
||||||
|
ozonetel: { ...current.ozonetel, ...(updates.ozonetel ?? {}) },
|
||||||
|
sip: { ...current.sip, ...(updates.sip ?? {}) },
|
||||||
|
exotel: { ...current.exotel, ...(updates.exotel ?? {}) },
|
||||||
|
version: (current.version ?? 0) + 1,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
// Strip the masked sentinel — admin UI sends back '***masked***' for
|
||||||
|
// unchanged secret fields. We treat that as "keep the existing value".
|
||||||
|
if (merged.exotel.apiToken === '***masked***') {
|
||||||
|
merged.exotel.apiToken = current.exotel.apiToken;
|
||||||
|
}
|
||||||
|
if (merged.ozonetel.agentPassword === '***masked***') {
|
||||||
|
merged.ozonetel.agentPassword = current.ozonetel.agentPassword;
|
||||||
|
}
|
||||||
|
if (merged.ozonetel.adminPassword === '***masked***') {
|
||||||
|
merged.ozonetel.adminPassword = current.ozonetel.adminPassword;
|
||||||
|
}
|
||||||
|
this.backup();
|
||||||
|
this.writeFile(merged);
|
||||||
|
this.cached = merged;
|
||||||
|
this.logger.log(`Telephony config updated to v${merged.version}`);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetConfig(): TelephonyConfig {
|
||||||
|
this.backup();
|
||||||
|
const fresh = JSON.parse(JSON.stringify(DEFAULT_TELEPHONY_CONFIG)) as TelephonyConfig;
|
||||||
|
this.writeFile(fresh);
|
||||||
|
this.cached = fresh;
|
||||||
|
this.logger.log('Telephony config reset to defaults');
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First-boot bootstrap: if no telephony.json exists yet, seed it from the
|
||||||
|
// legacy env vars. After this runs once the env vars are dead code.
|
||||||
|
private ensureReady(): TelephonyConfig {
|
||||||
|
if (existsSync(CONFIG_PATH)) {
|
||||||
|
return this.load();
|
||||||
|
}
|
||||||
|
const seeded: TelephonyConfig = JSON.parse(
|
||||||
|
JSON.stringify(DEFAULT_TELEPHONY_CONFIG),
|
||||||
|
) as TelephonyConfig;
|
||||||
|
let appliedCount = 0;
|
||||||
|
for (const seed of TELEPHONY_ENV_SEEDS) {
|
||||||
|
const value = process.env[seed.env];
|
||||||
|
if (value === undefined || value === '') continue;
|
||||||
|
this.setNested(seeded, seed.path, value);
|
||||||
|
appliedCount += 1;
|
||||||
|
}
|
||||||
|
seeded.version = 1;
|
||||||
|
seeded.updatedAt = new Date().toISOString();
|
||||||
|
this.writeFile(seeded);
|
||||||
|
this.cached = seeded;
|
||||||
|
this.logger.log(
|
||||||
|
`Telephony config seeded from env (${appliedCount} env var${appliedCount === 1 ? '' : 's'} applied)`,
|
||||||
|
);
|
||||||
|
return seeded;
|
||||||
|
}
|
||||||
|
|
||||||
|
private load(): TelephonyConfig {
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(CONFIG_PATH, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
const merged: TelephonyConfig = {
|
||||||
|
ozonetel: { ...DEFAULT_TELEPHONY_CONFIG.ozonetel, ...(parsed.ozonetel ?? {}) },
|
||||||
|
sip: { ...DEFAULT_TELEPHONY_CONFIG.sip, ...(parsed.sip ?? {}) },
|
||||||
|
exotel: { ...DEFAULT_TELEPHONY_CONFIG.exotel, ...(parsed.exotel ?? {}) },
|
||||||
|
version: parsed.version,
|
||||||
|
updatedAt: parsed.updatedAt,
|
||||||
|
};
|
||||||
|
this.cached = merged;
|
||||||
|
this.logger.log('Telephony config loaded from file');
|
||||||
|
return merged;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to load telephony config, using defaults: ${err}`);
|
||||||
|
const fresh = JSON.parse(JSON.stringify(DEFAULT_TELEPHONY_CONFIG)) as TelephonyConfig;
|
||||||
|
this.cached = fresh;
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setNested(obj: any, path: string[], value: string) {
|
||||||
|
let cursor = obj;
|
||||||
|
for (let i = 0; i < path.length - 1; i++) {
|
||||||
|
if (!cursor[path[i]]) cursor[path[i]] = {};
|
||||||
|
cursor = cursor[path[i]];
|
||||||
|
}
|
||||||
|
cursor[path[path.length - 1]] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeFile(cfg: TelephonyConfig) {
|
||||||
|
const dir = dirname(CONFIG_PATH);
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
private backup() {
|
||||||
|
try {
|
||||||
|
if (!existsSync(CONFIG_PATH)) return;
|
||||||
|
if (!existsSync(BACKUP_DIR)) mkdirSync(BACKUP_DIR, { recursive: true });
|
||||||
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
copyFileSync(CONFIG_PATH, join(BACKUP_DIR, `telephony-${ts}.json`));
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Telephony backup failed: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/config/telephony.defaults.ts
Normal file
86
src/config/telephony.defaults.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
// Admin-editable telephony config. Holds Ozonetel cloud-call-center settings,
|
||||||
|
// the Ozonetel SIP gateway info, and the Exotel REST API credentials.
|
||||||
|
//
|
||||||
|
// All of these used to live in env vars (OZONETEL_*, SIP_*, EXOTEL_*).
|
||||||
|
// On first boot, TelephonyConfigService seeds this file from those env vars
|
||||||
|
// so existing deployments keep working without manual migration. After that,
|
||||||
|
// admins edit via the staff portal "Telephony" settings page and the env vars
|
||||||
|
// are no longer read.
|
||||||
|
//
|
||||||
|
// SECRETS — note: EXOTEL_WEBHOOK_SECRET stays in env (true secret used for
|
||||||
|
// inbound webhook HMAC verification). EXOTEL_API_TOKEN is stored here because
|
||||||
|
// the admin must be able to rotate it from the UI. The GET endpoint masks it.
|
||||||
|
|
||||||
|
export type TelephonyConfig = {
|
||||||
|
ozonetel: {
|
||||||
|
// Default test agent — used by maintenance and provisioning flows.
|
||||||
|
agentId: string;
|
||||||
|
agentPassword: string;
|
||||||
|
// Default DID (the hospital's published number).
|
||||||
|
did: string;
|
||||||
|
// Default SIP extension that maps to a softphone session.
|
||||||
|
sipId: string;
|
||||||
|
// Default outbound campaign name on Ozonetel CloudAgent.
|
||||||
|
campaignName: string;
|
||||||
|
// Ozonetel portal admin credentials — used by supervisor barge/whisper/listen.
|
||||||
|
// These are the login credentials for the Ozonetel admin dashboard
|
||||||
|
// (api.cloudagent.ozonetel.com/auth/login), NOT an agent ID.
|
||||||
|
adminUsername: string;
|
||||||
|
adminPassword: string;
|
||||||
|
};
|
||||||
|
// Ozonetel WebRTC gateway used by the staff portal softphone.
|
||||||
|
sip: {
|
||||||
|
domain: string;
|
||||||
|
wsPort: string;
|
||||||
|
};
|
||||||
|
// Exotel REST API credentials for inbound number management + SMS.
|
||||||
|
exotel: {
|
||||||
|
apiKey: string;
|
||||||
|
apiToken: string;
|
||||||
|
accountSid: string;
|
||||||
|
subdomain: string;
|
||||||
|
};
|
||||||
|
version?: number;
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_TELEPHONY_CONFIG: TelephonyConfig = {
|
||||||
|
ozonetel: {
|
||||||
|
agentId: '',
|
||||||
|
agentPassword: '',
|
||||||
|
did: '',
|
||||||
|
sipId: '',
|
||||||
|
campaignName: '',
|
||||||
|
adminUsername: '',
|
||||||
|
adminPassword: '',
|
||||||
|
},
|
||||||
|
sip: {
|
||||||
|
domain: 'blr-pub-rtc4.ozonetel.com',
|
||||||
|
wsPort: '444',
|
||||||
|
},
|
||||||
|
exotel: {
|
||||||
|
apiKey: '',
|
||||||
|
apiToken: '',
|
||||||
|
accountSid: '',
|
||||||
|
subdomain: 'api.exotel.com',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Field-by-field mapping from legacy env var names to config paths. Used by
|
||||||
|
// the first-boot seeder. Keep in sync with the migration target sites.
|
||||||
|
export const TELEPHONY_ENV_SEEDS: Array<{ env: string; path: string[] }> = [
|
||||||
|
// OZONETEL_AGENT_ID removed — agentId is per-user on the Agent entity,
|
||||||
|
// not a sidecar-level config. All endpoints require agentId from caller.
|
||||||
|
{ env: 'OZONETEL_AGENT_PASSWORD', path: ['ozonetel', 'agentPassword'] },
|
||||||
|
{ env: 'OZONETEL_ADMIN_USERNAME', path: ['ozonetel', 'adminUsername'] },
|
||||||
|
{ env: 'OZONETEL_ADMIN_PASSWORD', path: ['ozonetel', 'adminPassword'] },
|
||||||
|
{ env: 'OZONETEL_DID', path: ['ozonetel', 'did'] },
|
||||||
|
{ env: 'OZONETEL_SIP_ID', path: ['ozonetel', 'sipId'] },
|
||||||
|
{ env: 'OZONETEL_CAMPAIGN_NAME', path: ['ozonetel', 'campaignName'] },
|
||||||
|
{ env: 'SIP_DOMAIN', path: ['sip', 'domain'] },
|
||||||
|
{ env: 'SIP_WS_PORT', path: ['sip', 'wsPort'] },
|
||||||
|
{ env: 'EXOTEL_API_KEY', path: ['exotel', 'apiKey'] },
|
||||||
|
{ env: 'EXOTEL_API_TOKEN', path: ['exotel', 'apiToken'] },
|
||||||
|
{ env: 'EXOTEL_ACCOUNT_SID', path: ['exotel', 'accountSid'] },
|
||||||
|
{ env: 'EXOTEL_SUBDOMAIN', path: ['exotel', 'subdomain'] },
|
||||||
|
];
|
||||||
50
src/config/widget-config.controller.ts
Normal file
50
src/config/widget-config.controller.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { Controller, Get, Put, Post, Body, Logger } from '@nestjs/common';
|
||||||
|
import { WidgetConfigService } from './widget-config.service';
|
||||||
|
import type { WidgetConfig } from './widget.defaults';
|
||||||
|
|
||||||
|
// Mounted under /api/config (same prefix as ThemeController).
|
||||||
|
//
|
||||||
|
// GET /api/config/widget — public subset, called by the embed
|
||||||
|
// page to decide whether & how to load
|
||||||
|
// widget.js
|
||||||
|
// GET /api/config/widget/admin — full config incl. origins + metadata
|
||||||
|
// PUT /api/config/widget — admin update (merge patch)
|
||||||
|
// POST /api/config/widget/rotate-key — rotate the HMAC site key
|
||||||
|
// POST /api/config/widget/reset — reset to defaults (regenerates key)
|
||||||
|
//
|
||||||
|
// TODO: protect the admin endpoints with the admin guard once the settings UI
|
||||||
|
// ships. Matches the current ThemeController convention (also currently open).
|
||||||
|
@Controller('api/config')
|
||||||
|
export class WidgetConfigController {
|
||||||
|
private readonly logger = new Logger(WidgetConfigController.name);
|
||||||
|
|
||||||
|
constructor(private readonly widgetConfig: WidgetConfigService) {}
|
||||||
|
|
||||||
|
@Get('widget')
|
||||||
|
getPublicWidget() {
|
||||||
|
return this.widgetConfig.getPublicConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('widget/admin')
|
||||||
|
getAdminWidget() {
|
||||||
|
return this.widgetConfig.getConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('widget')
|
||||||
|
async updateWidget(@Body() body: Partial<WidgetConfig>) {
|
||||||
|
this.logger.log('Widget config update request');
|
||||||
|
return this.widgetConfig.updateConfig(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('widget/rotate-key')
|
||||||
|
async rotateKey() {
|
||||||
|
this.logger.log('Widget key rotation request');
|
||||||
|
return this.widgetConfig.rotateKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('widget/reset')
|
||||||
|
async resetWidget() {
|
||||||
|
this.logger.log('Widget config reset request');
|
||||||
|
return this.widgetConfig.resetConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
202
src/config/widget-config.service.ts
Normal file
202
src/config/widget-config.service.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'fs';
|
||||||
|
import { join, dirname } from 'path';
|
||||||
|
import { DEFAULT_WIDGET_CONFIG, type WidgetConfig } from './widget.defaults';
|
||||||
|
import { WidgetKeysService } from './widget-keys.service';
|
||||||
|
import { ThemeService } from './theme.service';
|
||||||
|
|
||||||
|
const CONFIG_PATH = join(process.cwd(), 'data', 'widget.json');
|
||||||
|
const BACKUP_DIR = join(process.cwd(), 'data', 'widget-backups');
|
||||||
|
|
||||||
|
// File-backed store for admin-editable widget configuration. Mirrors ThemeService:
|
||||||
|
// - onModuleInit() → load from disk → ensure key exists (generate + persist)
|
||||||
|
// - getConfig() → in-memory cached lookup
|
||||||
|
// - updateConfig() → merge patch + backup + write + bump version
|
||||||
|
// - rotateKey() → revoke old siteId in Redis + generate new + persist
|
||||||
|
//
|
||||||
|
// Also guarantees the key stays valid across Redis flushes: if the file has a
|
||||||
|
// key but Redis doesn't know about its siteId, we silently re-register it on
|
||||||
|
// boot so POST /api/widget/* requests keep authenticating.
|
||||||
|
@Injectable()
|
||||||
|
export class WidgetConfigService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(WidgetConfigService.name);
|
||||||
|
private cached: WidgetConfig | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly widgetKeys: WidgetKeysService,
|
||||||
|
private readonly theme: ThemeService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// Hospital name comes from the theme — single source of truth. The widget
|
||||||
|
// key's Redis label is just bookkeeping; pulling it from theme means
|
||||||
|
// renaming the hospital via /branding-settings flows through to the next
|
||||||
|
// key rotation automatically.
|
||||||
|
private get hospitalName(): string {
|
||||||
|
return this.theme.getTheme().brand.hospitalName;
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.ensureReady();
|
||||||
|
}
|
||||||
|
|
||||||
|
getConfig(): WidgetConfig {
|
||||||
|
if (this.cached) return this.cached;
|
||||||
|
return this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public-facing subset served from GET /api/config/widget. Only the fields
|
||||||
|
// the embed bootstrap code needs — no origins, no hospital label, no
|
||||||
|
// version metadata.
|
||||||
|
getPublicConfig() {
|
||||||
|
const c = this.getConfig();
|
||||||
|
return {
|
||||||
|
enabled: c.enabled,
|
||||||
|
key: c.key,
|
||||||
|
url: c.url,
|
||||||
|
embed: c.embed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateConfig(updates: Partial<WidgetConfig>): Promise<WidgetConfig> {
|
||||||
|
const current = this.getConfig();
|
||||||
|
const merged: WidgetConfig = {
|
||||||
|
...current,
|
||||||
|
...updates,
|
||||||
|
embed: { ...current.embed, ...updates.embed },
|
||||||
|
allowedOrigins: updates.allowedOrigins ?? current.allowedOrigins,
|
||||||
|
version: (current.version ?? 0) + 1,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.backup();
|
||||||
|
this.writeFile(merged);
|
||||||
|
this.cached = merged;
|
||||||
|
this.logger.log(`Widget config updated to v${merged.version}`);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke the current siteId in Redis, mint a new key with the current
|
||||||
|
// theme.brand.hospitalName + allowedOrigins, persist both the Redis entry
|
||||||
|
// and the config file. Used by admins to invalidate a leaked or stale key.
|
||||||
|
async rotateKey(): Promise<WidgetConfig> {
|
||||||
|
const current = this.getConfig();
|
||||||
|
if (current.siteId) {
|
||||||
|
await this.widgetKeys.revokeKey(current.siteId).catch(err => {
|
||||||
|
this.logger.warn(`Revoke of old siteId ${current.siteId} failed: ${err}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const { key, siteKey } = this.widgetKeys.generateKey(
|
||||||
|
this.hospitalName,
|
||||||
|
current.allowedOrigins,
|
||||||
|
);
|
||||||
|
await this.widgetKeys.saveKey(siteKey);
|
||||||
|
|
||||||
|
const updated: WidgetConfig = {
|
||||||
|
...current,
|
||||||
|
key,
|
||||||
|
siteId: siteKey.siteId,
|
||||||
|
version: (current.version ?? 0) + 1,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.backup();
|
||||||
|
this.writeFile(updated);
|
||||||
|
this.cached = updated;
|
||||||
|
this.logger.log(`Widget key rotated: new siteId=${siteKey.siteId}`);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetConfig(): Promise<WidgetConfig> {
|
||||||
|
this.backup();
|
||||||
|
this.writeFile(DEFAULT_WIDGET_CONFIG);
|
||||||
|
this.cached = { ...DEFAULT_WIDGET_CONFIG };
|
||||||
|
this.logger.log('Widget config reset to defaults');
|
||||||
|
return this.ensureReady();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureReady(): Promise<WidgetConfig> {
|
||||||
|
let cfg = this.load();
|
||||||
|
|
||||||
|
// First boot or missing key → generate + persist.
|
||||||
|
const needsKey = !cfg.key || !cfg.siteId;
|
||||||
|
if (needsKey) {
|
||||||
|
this.logger.log('No widget key in config — generating a fresh one');
|
||||||
|
const { key, siteKey } = this.widgetKeys.generateKey(
|
||||||
|
this.hospitalName,
|
||||||
|
cfg.allowedOrigins,
|
||||||
|
);
|
||||||
|
await this.widgetKeys.saveKey(siteKey);
|
||||||
|
cfg = {
|
||||||
|
...cfg,
|
||||||
|
key,
|
||||||
|
siteId: siteKey.siteId,
|
||||||
|
// Allow WIDGET_PUBLIC_URL env var to seed the url field on
|
||||||
|
// first boot, so dev/staging don't start with a blank URL.
|
||||||
|
url: cfg.url || process.env.WIDGET_PUBLIC_URL || '',
|
||||||
|
version: (cfg.version ?? 0) + 1,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.writeFile(cfg);
|
||||||
|
this.cached = cfg;
|
||||||
|
this.logger.log(`Widget key generated: siteId=${siteKey.siteId}`);
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key exists on disk but may be missing from Redis (e.g., Redis
|
||||||
|
// flushed or sidecar migrated to new Redis). Re-register so requests
|
||||||
|
// validate correctly. This is silent — callers don't care.
|
||||||
|
const validated = await this.widgetKeys.validateKey(cfg.key).catch(() => null);
|
||||||
|
if (!validated) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Widget key in config not found in Redis — re-registering siteId=${cfg.siteId}`,
|
||||||
|
);
|
||||||
|
await this.widgetKeys.saveKey({
|
||||||
|
siteId: cfg.siteId,
|
||||||
|
hospitalName: this.hospitalName,
|
||||||
|
allowedOrigins: cfg.allowedOrigins,
|
||||||
|
active: true,
|
||||||
|
createdAt: cfg.updatedAt ?? new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private load(): WidgetConfig {
|
||||||
|
try {
|
||||||
|
if (existsSync(CONFIG_PATH)) {
|
||||||
|
const raw = readFileSync(CONFIG_PATH, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
const merged: WidgetConfig = {
|
||||||
|
...DEFAULT_WIDGET_CONFIG,
|
||||||
|
...parsed,
|
||||||
|
embed: { ...DEFAULT_WIDGET_CONFIG.embed, ...parsed.embed },
|
||||||
|
allowedOrigins: parsed.allowedOrigins ?? DEFAULT_WIDGET_CONFIG.allowedOrigins,
|
||||||
|
};
|
||||||
|
this.cached = merged;
|
||||||
|
this.logger.log('Widget config loaded from file');
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to load widget config: ${err}`);
|
||||||
|
}
|
||||||
|
const fallback: WidgetConfig = { ...DEFAULT_WIDGET_CONFIG };
|
||||||
|
this.cached = fallback;
|
||||||
|
this.logger.log('Using default widget config');
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeFile(cfg: WidgetConfig) {
|
||||||
|
const dir = dirname(CONFIG_PATH);
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
private backup() {
|
||||||
|
try {
|
||||||
|
if (!existsSync(CONFIG_PATH)) return;
|
||||||
|
if (!existsSync(BACKUP_DIR)) mkdirSync(BACKUP_DIR, { recursive: true });
|
||||||
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
copyFileSync(CONFIG_PATH, join(BACKUP_DIR, `widget-${ts}.json`));
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Widget config backup failed: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/config/widget-keys.service.ts
Normal file
94
src/config/widget-keys.service.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { createHmac, timingSafeEqual, randomUUID } from 'crypto';
|
||||||
|
import { SessionService } from '../auth/session.service';
|
||||||
|
import type { WidgetSiteKey } from '../widget/widget.types';
|
||||||
|
|
||||||
|
const KEY_PREFIX = 'widget:keys:';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WidgetKeysService {
|
||||||
|
private readonly logger = new Logger(WidgetKeysService.name);
|
||||||
|
private readonly secret: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private config: ConfigService,
|
||||||
|
private session: SessionService,
|
||||||
|
) {
|
||||||
|
this.secret = process.env.WIDGET_SECRET ?? config.get<string>('WIDGET_SECRET') ?? 'helix-widget-default-secret';
|
||||||
|
}
|
||||||
|
|
||||||
|
generateKey(hospitalName: string, allowedOrigins: string[]): { key: string; siteKey: WidgetSiteKey } {
|
||||||
|
const siteId = randomUUID().replace(/-/g, '').substring(0, 16);
|
||||||
|
const signature = this.sign(siteId);
|
||||||
|
const key = `${siteId}.${signature}`;
|
||||||
|
|
||||||
|
const siteKey: WidgetSiteKey = {
|
||||||
|
siteId,
|
||||||
|
hospitalName,
|
||||||
|
allowedOrigins,
|
||||||
|
active: true,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return { key, siteKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveKey(siteKey: WidgetSiteKey): Promise<void> {
|
||||||
|
await this.session.setCachePersistent(`${KEY_PREFIX}${siteKey.siteId}`, JSON.stringify(siteKey));
|
||||||
|
this.logger.log(`Widget key saved: ${siteKey.siteId} (${siteKey.hospitalName})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateKey(rawKey: string): Promise<WidgetSiteKey | null> {
|
||||||
|
const dotIndex = rawKey.indexOf('.');
|
||||||
|
if (dotIndex === -1) return null;
|
||||||
|
|
||||||
|
const siteId = rawKey.substring(0, dotIndex);
|
||||||
|
const signature = rawKey.substring(dotIndex + 1);
|
||||||
|
|
||||||
|
const expected = this.sign(siteId);
|
||||||
|
try {
|
||||||
|
if (!timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'))) return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await this.session.getCache(`${KEY_PREFIX}${siteId}`);
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const siteKey: WidgetSiteKey = JSON.parse(data);
|
||||||
|
if (!siteKey.active) return null;
|
||||||
|
|
||||||
|
return siteKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
validateOrigin(siteKey: WidgetSiteKey, origin: string | undefined): boolean {
|
||||||
|
if (!origin) return true; // Allow no-origin for dev/testing
|
||||||
|
if (siteKey.allowedOrigins.length === 0) return true;
|
||||||
|
return siteKey.allowedOrigins.some(allowed => origin.startsWith(allowed));
|
||||||
|
}
|
||||||
|
|
||||||
|
async listKeys(): Promise<WidgetSiteKey[]> {
|
||||||
|
const keys = await this.session.scanKeys(`${KEY_PREFIX}*`);
|
||||||
|
const results: WidgetSiteKey[] = [];
|
||||||
|
for (const key of keys) {
|
||||||
|
const data = await this.session.getCache(key);
|
||||||
|
if (data) results.push(JSON.parse(data));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeKey(siteId: string): Promise<boolean> {
|
||||||
|
const data = await this.session.getCache(`${KEY_PREFIX}${siteId}`);
|
||||||
|
if (!data) return false;
|
||||||
|
const siteKey: WidgetSiteKey = JSON.parse(data);
|
||||||
|
siteKey.active = false;
|
||||||
|
await this.session.setCachePersistent(`${KEY_PREFIX}${siteId}`, JSON.stringify(siteKey));
|
||||||
|
this.logger.log(`Widget key revoked: ${siteId}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sign(data: string): string {
|
||||||
|
return createHmac('sha256', this.secret).update(data).digest('hex');
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/config/widget.defaults.ts
Normal file
46
src/config/widget.defaults.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// Shape of the website-widget configuration, stored in data/widget.json.
|
||||||
|
// Mirrors the theme config pattern — file-backed, versioned, admin-editable.
|
||||||
|
export type WidgetConfig = {
|
||||||
|
// Master feature flag. When false, the widget does not render anywhere.
|
||||||
|
enabled: boolean;
|
||||||
|
|
||||||
|
// HMAC-signed site key the embed script passes as data-key. Auto-generated
|
||||||
|
// on first boot if empty. Rotate via POST /api/config/widget/rotate-key.
|
||||||
|
key: string;
|
||||||
|
|
||||||
|
// Stable site identifier derived from the key. Used for Redis lookup and
|
||||||
|
// revocation. Populated alongside `key`.
|
||||||
|
siteId: string;
|
||||||
|
|
||||||
|
// Public base URL where widget.js is hosted. Typically the sidecar host.
|
||||||
|
// If empty, the embed page falls back to its own VITE_API_URL at fetch time.
|
||||||
|
url: string;
|
||||||
|
|
||||||
|
// Origin allowlist. Empty array means any origin is accepted (test mode).
|
||||||
|
// Set tight values in production: ['https://hospital.com'].
|
||||||
|
allowedOrigins: string[];
|
||||||
|
|
||||||
|
// Embed toggles — where the widget should render. Kept as an object so we
|
||||||
|
// can add other surfaces (public landing page, portal, etc.) without a
|
||||||
|
// breaking schema change.
|
||||||
|
embed: {
|
||||||
|
// Show on the staff login page. Useful for testing without a public
|
||||||
|
// landing page; turn off in production.
|
||||||
|
loginPage: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bookkeeping — incremented on every update, like the theme config.
|
||||||
|
version?: number;
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_WIDGET_CONFIG: WidgetConfig = {
|
||||||
|
enabled: true,
|
||||||
|
key: '',
|
||||||
|
siteId: '',
|
||||||
|
url: '',
|
||||||
|
allowedOrigins: [],
|
||||||
|
embed: {
|
||||||
|
loginPage: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -8,6 +8,7 @@ import type { CallCompletedEvent } from '../event-types';
|
|||||||
import { PlatformGraphqlService } from '../../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../../platform/platform-graphql.service';
|
||||||
import { createAiModel } from '../../ai/ai-provider';
|
import { createAiModel } from '../../ai/ai-provider';
|
||||||
import type { LanguageModel } from 'ai';
|
import type { LanguageModel } from 'ai';
|
||||||
|
import { AiConfigService } from '../../config/ai-config.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AiInsightConsumer implements OnModuleInit {
|
export class AiInsightConsumer implements OnModuleInit {
|
||||||
@@ -18,8 +19,15 @@ export class AiInsightConsumer implements OnModuleInit {
|
|||||||
private eventBus: EventBusService,
|
private eventBus: EventBusService,
|
||||||
private platform: PlatformGraphqlService,
|
private platform: PlatformGraphqlService,
|
||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
|
private aiConfig: AiConfigService,
|
||||||
) {
|
) {
|
||||||
this.aiModel = createAiModel(config);
|
const cfg = aiConfig.getConfig();
|
||||||
|
this.aiModel = createAiModel({
|
||||||
|
provider: cfg.provider,
|
||||||
|
model: cfg.model,
|
||||||
|
anthropicApiKey: config.get<string>('ai.anthropicApiKey'),
|
||||||
|
openaiApiKey: config.get<string>('ai.openaiApiKey'),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
@@ -74,11 +82,9 @@ export class AiInsightConsumer implements OnModuleInit {
|
|||||||
summary: z.string().describe('2-3 sentence summary of this lead based on all their interactions'),
|
summary: z.string().describe('2-3 sentence summary of this lead based on all their interactions'),
|
||||||
suggestedAction: z.string().describe('One clear next action for the agent'),
|
suggestedAction: z.string().describe('One clear next action for the agent'),
|
||||||
}),
|
}),
|
||||||
system: `You are a CRM assistant for Global Hospital Bangalore.
|
system: this.aiConfig.renderPrompt('callInsight', {
|
||||||
Generate a brief, actionable insight about this lead based on their interaction history.
|
hospitalName: process.env.HOSPITAL_NAME ?? 'the hospital',
|
||||||
Be specific — reference actual dates, dispositions, and patterns.
|
}),
|
||||||
If the lead has booked appointments, mention upcoming ones.
|
|
||||||
If they keep calling about the same thing, note the pattern.`,
|
|
||||||
prompt: `Lead: ${leadName}
|
prompt: `Lead: ${leadName}
|
||||||
Status: ${lead.status ?? 'Unknown'}
|
Status: ${lead.status ?? 'Unknown'}
|
||||||
Source: ${lead.source ?? 'Unknown'}
|
Source: ${lead.source ?? 'Unknown'}
|
||||||
|
|||||||
182
src/leads/lead-auto-assign.service.ts
Normal file
182
src/leads/lead-auto-assign.service.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { SupervisorService } from '../supervisor/supervisor.service';
|
||||||
|
|
||||||
|
const TICK_INTERVAL_MS = 60 * 1000; // 60s
|
||||||
|
const KICKOFF_DELAY_MS = 45_000; // let sidecar boot settle
|
||||||
|
const MAX_LEADS_PER_TICK = 100; // guard against runaway batches
|
||||||
|
const ACTIVE_STATES = new Set(['ready', 'calling', 'in-call', 'acw']);
|
||||||
|
// Excluded: 'offline' (agent logged out), 'break' / 'training' (explicitly away).
|
||||||
|
// ACW is included — the agent is still handling work and will return to Ready soon.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polls for unassigned leads every 60s and assigns them least-loaded across
|
||||||
|
* active agents.
|
||||||
|
*
|
||||||
|
* Why polling instead of platform functions or Redpanda events:
|
||||||
|
* - The platform's lead.created hook isn't wired to the sidecar (no bridge)
|
||||||
|
* - The SDK's lead-auto-assign.function.ts is written but hasn't been
|
||||||
|
* deployed/published to either workspace
|
||||||
|
* - Polling catches EVERY lead creation path (CSV import, enquiry form,
|
||||||
|
* missed-call webhook, widget, livekit) with no per-path instrumentation
|
||||||
|
*
|
||||||
|
* Assignment strategy:
|
||||||
|
* - Count each active agent's OPEN leads (status in NEW/CONTACTED/QUALIFIED)
|
||||||
|
* - Pick the agent with the lowest count — ties broken by platform ordering
|
||||||
|
* - Write agent.name (display name) to lead.assignedAgent (worklist filter matches on this)
|
||||||
|
*
|
||||||
|
* Edge cases:
|
||||||
|
* - No active agents → skip tick; next run retries
|
||||||
|
* - agentName empty → skip agent
|
||||||
|
* - Mutation errors → log, continue with next lead
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class LeadAutoAssignService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(LeadAutoAssignService.name);
|
||||||
|
private timer: NodeJS.Timeout | null = null;
|
||||||
|
private running = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly platform: PlatformGraphqlService,
|
||||||
|
private readonly supervisor: SupervisorService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.runOnce().catch((err) => this.logger.warn(`[AUTO-ASSIGN] Kickoff failed: ${err?.message ?? err}`));
|
||||||
|
}, KICKOFF_DELAY_MS);
|
||||||
|
|
||||||
|
this.timer = setInterval(() => {
|
||||||
|
this.runOnce().catch((err) => this.logger.warn(`[AUTO-ASSIGN] Tick failed: ${err?.message ?? err}`));
|
||||||
|
}, TICK_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleDestroy() {
|
||||||
|
if (this.timer) clearInterval(this.timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async runOnce(): Promise<{ assigned: number; skipped: number; noAgents: boolean }> {
|
||||||
|
// Guard against concurrent runs (prev tick hasn't finished).
|
||||||
|
if (this.running) return { assigned: 0, skipped: 0, noAgents: false };
|
||||||
|
this.running = true;
|
||||||
|
try {
|
||||||
|
const unassigned = await this.fetchUnassignedLeads();
|
||||||
|
if (unassigned.length === 0) return { assigned: 0, skipped: 0, noAgents: false };
|
||||||
|
|
||||||
|
const active = await this.fetchActiveAgents();
|
||||||
|
if (active.length === 0) {
|
||||||
|
this.logger.debug(`[AUTO-ASSIGN] ${unassigned.length} leads waiting — no active agents`);
|
||||||
|
return { assigned: 0, skipped: unassigned.length, noAgents: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed current-load map: lead count per agent across their OPEN leads.
|
||||||
|
// Fetch once per tick (not per lead) — the map is updated locally as we assign.
|
||||||
|
const loadByAgent = await this.fetchOpenLeadCounts(active.map((a) => a.name));
|
||||||
|
|
||||||
|
let assigned = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for (const lead of unassigned) {
|
||||||
|
// Pick the least-loaded active agent.
|
||||||
|
const target = [...active].sort(
|
||||||
|
(a, b) => (loadByAgent.get(a.name) ?? 0) - (loadByAgent.get(b.name) ?? 0),
|
||||||
|
)[0];
|
||||||
|
if (!target?.name) { skipped++; continue; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: lead.id, data: { assignedAgent: target.name } },
|
||||||
|
);
|
||||||
|
assigned++;
|
||||||
|
loadByAgent.set(target.name, (loadByAgent.get(target.name) ?? 0) + 1);
|
||||||
|
await new Promise((r) => setTimeout(r, 40)); // gentle pacing
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`[AUTO-ASSIGN] updateLead failed for ${lead.id}: ${err?.message ?? err}`);
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assigned > 0 || skipped > 0) {
|
||||||
|
const loadSummary = active.map((a) => `${a.name}=${loadByAgent.get(a.name) ?? 0}`).join(', ');
|
||||||
|
this.logger.log(`[AUTO-ASSIGN] Pass complete — assigned=${assigned} skipped=${skipped} load=[${loadSummary}]`);
|
||||||
|
}
|
||||||
|
return { assigned, skipped, noAgents: false };
|
||||||
|
} finally {
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchUnassignedLeads(): Promise<Array<{ id: string; campaignId: string | null }>> {
|
||||||
|
try {
|
||||||
|
const data: any = await this.platform.query<any>(
|
||||||
|
`{ leads(first: ${MAX_LEADS_PER_TICK}, filter: {
|
||||||
|
or: [
|
||||||
|
{ assignedAgent: { eq: "" } },
|
||||||
|
{ assignedAgent: { is: NULL } }
|
||||||
|
]
|
||||||
|
}, orderBy: [{ createdAt: AscNullsLast }]) {
|
||||||
|
edges { node { id campaignId } }
|
||||||
|
} }`,
|
||||||
|
);
|
||||||
|
return (data?.leads?.edges ?? []).map((e: any) => e.node);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`[AUTO-ASSIGN] fetch unassigned failed: ${err?.message ?? err}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchActiveAgents(): Promise<Array<{ id: string; name: string; ozonetelAgentId: string }>> {
|
||||||
|
try {
|
||||||
|
const data: any = await this.platform.query<any>(
|
||||||
|
`{ agents(first: 100) { edges { node { id name ozonetelAgentId } } } }`,
|
||||||
|
);
|
||||||
|
const all: Array<{ id: string; name: string; ozonetelAgentId: string }> =
|
||||||
|
(data?.agents?.edges ?? []).map((e: any) => e.node);
|
||||||
|
// Filter to agents whose in-memory state (from Ozonetel webhooks) is active.
|
||||||
|
// If state is unknown (never seen a state event), treat as offline.
|
||||||
|
return all.filter((a) => {
|
||||||
|
if (!a.name || !a.ozonetelAgentId) return false;
|
||||||
|
const entry = this.supervisor.getAgentState(a.ozonetelAgentId);
|
||||||
|
return entry ? ACTIVE_STATES.has(entry.state) : false;
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`[AUTO-ASSIGN] fetch agents failed: ${err?.message ?? err}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchOpenLeadCounts(agentNames: string[]): Promise<Map<string, number>> {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (const name of agentNames) map.set(name, 0);
|
||||||
|
if (agentNames.length === 0) return map;
|
||||||
|
|
||||||
|
// Single aggregated query — pull ALL open leads with assignedAgent set,
|
||||||
|
// count by agent locally. Avoids N+1 over agents.
|
||||||
|
try {
|
||||||
|
let after: string | null = null;
|
||||||
|
for (let page = 0; page < 20; page++) {
|
||||||
|
const cursor: string = after ? `, after: "${after}"` : '';
|
||||||
|
const data: any = await this.platform.query<any>(
|
||||||
|
`{ leads(first: 200${cursor}, filter: {
|
||||||
|
status: { in: [NEW, CONTACTED, QUALIFIED] }
|
||||||
|
}) {
|
||||||
|
edges { node { assignedAgent } }
|
||||||
|
pageInfo { hasNextPage endCursor }
|
||||||
|
} }`,
|
||||||
|
);
|
||||||
|
const edges = data?.leads?.edges ?? [];
|
||||||
|
for (const e of edges) {
|
||||||
|
const name = e.node.assignedAgent;
|
||||||
|
if (name && map.has(name)) map.set(name, (map.get(name) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const info: { hasNextPage?: boolean; endCursor?: string } = data?.leads?.pageInfo ?? {};
|
||||||
|
if (!info.hasNextPage) break;
|
||||||
|
after = info.endCursor ?? null;
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`[AUTO-ASSIGN] fetch open-lead counts failed: ${err?.message ?? err}`);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/leads/leads.module.ts
Normal file
11
src/leads/leads.module.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
import { SupervisorModule } from '../supervisor/supervisor.module';
|
||||||
|
import { LeadAutoAssignService } from './lead-auto-assign.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PlatformModule, forwardRef(() => SupervisorModule)],
|
||||||
|
providers: [LeadAutoAssignService],
|
||||||
|
exports: [LeadAutoAssignService],
|
||||||
|
})
|
||||||
|
export class LeadsModule {}
|
||||||
@@ -27,6 +27,27 @@ async function gql<T = any>(query: string, variables?: Record<string, unknown>):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve a phone to a {leadId, patientId} pair via the sidecar's
|
||||||
|
// caller-resolution endpoint. Always returns populated IDs (creates
|
||||||
|
// placeholder lead+patient when none exist).
|
||||||
|
async function resolveCaller(phone: string): Promise<{ leadId: string; patientId: string; firstName: string; lastName: string; isNew: boolean } | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${SIDECAR_URL}/api/caller/resolve`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ phone }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error('[AGENT-RESOLVE] Failed:', res.status, await res.text().catch(() => ''));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return await res.json();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[AGENT-RESOLVE] Failed:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Hospital context — loaded on startup
|
// Hospital context — loaded on startup
|
||||||
let hospitalContext = {
|
let hospitalContext = {
|
||||||
doctors: [] as Array<{ name: string; department: string; specialty: string; id: string }>,
|
doctors: [] as Array<{ name: string; department: string; specialty: string; id: string }>,
|
||||||
@@ -128,28 +149,58 @@ const bookAppointment = llm.tool({
|
|||||||
doctorName: doctor?.name ?? doctorName ?? 'To be assigned',
|
doctorName: doctor?.name ?? doctorName ?? 'To be assigned',
|
||||||
department,
|
department,
|
||||||
reasonForVisit: reason,
|
reasonForVisit: reason,
|
||||||
|
...((doctor as any)?.clinicId ? { clinicId: (doctor as any).clinicId } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create or find lead
|
// Resolve caller — if isNew, create Lead + Patient with the
|
||||||
|
// AI-collected name; otherwise update the existing record.
|
||||||
const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10);
|
const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10);
|
||||||
|
const resolved = await resolveCaller(cleanPhone);
|
||||||
|
const fn = patientName.split(' ')[0];
|
||||||
|
const ln = patientName.split(' ').slice(1).join(' ') || '';
|
||||||
|
if (resolved?.isNew) {
|
||||||
|
const p = await gql(
|
||||||
|
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||||
|
{ data: { fullName: { firstName: fn, lastName: ln }, phones: { primaryPhoneNumber: `+91${cleanPhone}` }, patientType: 'NEW' } },
|
||||||
|
);
|
||||||
|
const newPatientId = p?.createPatient?.id;
|
||||||
await gql(
|
await gql(
|
||||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||||
{
|
{
|
||||||
data: {
|
data: {
|
||||||
name: `AI — ${patientName}`,
|
name: `AI — ${patientName}`,
|
||||||
contactName: {
|
contactName: { firstName: fn, lastName: ln },
|
||||||
firstName: patientName.split(' ')[0],
|
|
||||||
lastName: patientName.split(' ').slice(1).join(' ') || '',
|
|
||||||
},
|
|
||||||
contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` },
|
contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` },
|
||||||
source: 'PHONE',
|
source: 'PHONE',
|
||||||
status: 'APPOINTMENT_SET',
|
status: 'APPOINTMENT_SET',
|
||||||
interestedService: department,
|
interestedService: department,
|
||||||
|
...(newPatientId ? { patientId: newPatientId } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else if (resolved?.leadId) {
|
||||||
|
await gql(
|
||||||
|
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
id: resolved.leadId,
|
||||||
|
data: {
|
||||||
|
name: `AI — ${patientName}`,
|
||||||
|
contactName: { firstName: fn, lastName: ln },
|
||||||
|
source: 'PHONE',
|
||||||
|
status: 'APPOINTMENT_SET',
|
||||||
|
interestedService: department,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
if (resolved.patientId) {
|
||||||
|
await gql(
|
||||||
|
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: resolved.patientId, data: { fullName: { firstName: fn, lastName: ln } } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const refNum = `GH-${Date.now().toString().slice(-6)}`;
|
const refNum = `GH-${Date.now().toString().slice(-6)}`;
|
||||||
if (result?.createAppointment?.id) {
|
if (result?.createAppointment?.id) {
|
||||||
@@ -171,25 +222,53 @@ const collectLeadInfo = llm.tool({
|
|||||||
console.log(`[LIVEKIT-AGENT] Lead: ${name} | ${phoneNumber} | ${interest}`);
|
console.log(`[LIVEKIT-AGENT] Lead: ${name} | ${phoneNumber} | ${interest}`);
|
||||||
|
|
||||||
const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10);
|
const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10);
|
||||||
const result = await gql(
|
const resolved = await resolveCaller(cleanPhone);
|
||||||
|
const fn = name.split(' ')[0];
|
||||||
|
const ln = name.split(' ').slice(1).join(' ') || '';
|
||||||
|
|
||||||
|
if (resolved?.isNew) {
|
||||||
|
// Net-new caller — create Patient + Lead with the AI-collected name.
|
||||||
|
const p = await gql(
|
||||||
|
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||||
|
{ data: { fullName: { firstName: fn, lastName: ln }, phones: { primaryPhoneNumber: `+91${cleanPhone}` }, patientType: 'NEW' } },
|
||||||
|
);
|
||||||
|
const newPatientId = p?.createPatient?.id;
|
||||||
|
const created = await gql(
|
||||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||||
{
|
{
|
||||||
data: {
|
data: {
|
||||||
name: `AI Enquiry — ${name}`,
|
name: `AI Enquiry — ${name}`,
|
||||||
contactName: {
|
contactName: { firstName: fn, lastName: ln },
|
||||||
firstName: name.split(' ')[0],
|
|
||||||
lastName: name.split(' ').slice(1).join(' ') || '',
|
|
||||||
},
|
|
||||||
contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` },
|
contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` },
|
||||||
source: 'PHONE',
|
source: 'PHONE',
|
||||||
status: 'NEW',
|
status: 'NEW',
|
||||||
interestedService: interest,
|
interestedService: interest,
|
||||||
|
...(newPatientId ? { patientId: newPatientId } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
console.log(`[LIVEKIT-AGENT] Lead created: ${created?.createLead?.id ?? 'none'} (patient ${newPatientId ?? 'none'})`);
|
||||||
|
} else if (resolved?.leadId) {
|
||||||
|
await gql(
|
||||||
|
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
id: resolved.leadId,
|
||||||
|
data: {
|
||||||
|
name: `AI Enquiry — ${name}`,
|
||||||
|
contactName: { firstName: fn, lastName: ln },
|
||||||
|
source: 'PHONE',
|
||||||
|
status: 'NEW',
|
||||||
|
interestedService: interest,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
if (resolved.patientId) {
|
||||||
if (result?.createLead?.id) {
|
await gql(
|
||||||
console.log(`[LIVEKIT-AGENT] Lead created: ${result.createLead.id}`);
|
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: resolved.patientId, data: { fullName: { firstName: fn, lastName: ln } } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(`[LIVEKIT-AGENT] Lead updated: ${resolved.leadId} (patient ${resolved.patientId})`);
|
||||||
}
|
}
|
||||||
return `Thank you ${name}. I have noted your enquiry about ${interest}. One of our team members will call you back on ${phoneNumber} shortly.`;
|
return `Thank you ${name}. I have noted your enquiry about ${interest}. One of our team members will call you back on ${phoneNumber} shortly.`;
|
||||||
},
|
},
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/main.ts
17
src/main.ts
@@ -1,9 +1,13 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
|
import { join } from 'path';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { LogStreamService } from './logging/log-stream.service';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const logger = LogStreamService.instance;
|
||||||
|
const app = await NestFactory.create<NestExpressApplication>(AppModule, { logger });
|
||||||
const config = app.get(ConfigService);
|
const config = app.get(ConfigService);
|
||||||
|
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
@@ -11,6 +15,17 @@ async function bootstrap() {
|
|||||||
credentials: true,
|
credentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Serve widget.js and other static files from /public
|
||||||
|
// In dev mode __dirname = src/, in prod __dirname = dist/ — resolve from process.cwd()
|
||||||
|
app.useStaticAssets(join(process.cwd(), 'public'), {
|
||||||
|
setHeaders: (res, path) => {
|
||||||
|
if (path.endsWith('.js')) {
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const port = config.get('port');
|
const port = config.get('port');
|
||||||
await app.listen(port);
|
await app.listen(port);
|
||||||
console.log(`Helix Engage Server running on port ${port}`);
|
console.log(`Helix Engage Server running on port ${port}`);
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { Controller, Post, UseGuards, Logger } from '@nestjs/common';
|
import { Body, Controller, HttpException, Post, UseGuards, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { MaintGuard } from './maint.guard';
|
import { MaintGuard } from './maint.guard';
|
||||||
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
import { SessionService } from '../auth/session.service';
|
import { SessionService } from '../auth/session.service';
|
||||||
import { SupervisorService } from '../supervisor/supervisor.service';
|
import { SupervisorService } from '../supervisor/supervisor.service';
|
||||||
|
import { AgentHistoryService, AgentEventType } from '../supervisor/agent-history.service';
|
||||||
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||||
|
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||||
|
import { AgentLookupService } from '../platform/agent-lookup.service';
|
||||||
|
import { CdrEnrichmentService } from '../ozonetel/cdr-enrichment.service';
|
||||||
|
|
||||||
@Controller('api/maint')
|
@Controller('api/maint')
|
||||||
@UseGuards(MaintGuard)
|
@UseGuards(MaintGuard)
|
||||||
@@ -13,21 +16,41 @@ export class MaintController {
|
|||||||
private readonly logger = new Logger(MaintController.name);
|
private readonly logger = new Logger(MaintController.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly config: ConfigService,
|
private readonly telephony: TelephonyConfigService,
|
||||||
private readonly ozonetel: OzonetelAgentService,
|
private readonly ozonetel: OzonetelAgentService,
|
||||||
private readonly platform: PlatformGraphqlService,
|
private readonly platform: PlatformGraphqlService,
|
||||||
private readonly session: SessionService,
|
private readonly session: SessionService,
|
||||||
private readonly supervisor: SupervisorService,
|
private readonly supervisor: SupervisorService,
|
||||||
private readonly callerResolution: CallerResolutionService,
|
private readonly callerResolution: CallerResolutionService,
|
||||||
|
private readonly history: AgentHistoryService,
|
||||||
|
private readonly agentLookup: AgentLookupService,
|
||||||
|
private readonly cdrEnrichment: CdrEnrichmentService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('force-ready')
|
@Post('force-ready')
|
||||||
async forceReady() {
|
async forceReady(@Body() body: { agentId: string }) {
|
||||||
const agentId = this.config.get<string>('OZONETEL_AGENT_ID') ?? 'agent3';
|
if (!body?.agentId) throw new HttpException('agentId required', 400);
|
||||||
const password = process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$';
|
const agentId = body.agentId;
|
||||||
const sipId = this.config.get<string>('OZONETEL_SIP_ID') ?? '521814';
|
|
||||||
|
|
||||||
this.logger.log(`[MAINT] Force ready: agent=${agentId}`);
|
// Look up the Agent entity to get sipPassword + sipExtension.
|
||||||
|
// Password comes from the Agent record, not an env var — each
|
||||||
|
// agent owns their own Ozonetel credential.
|
||||||
|
const agentData = await this.platform.query<any>(
|
||||||
|
`{ agents(first: 1, filter: { ozonetelAgentId: { eq: "${agentId}" } }) { edges { node {
|
||||||
|
id sipExtension sipPassword
|
||||||
|
} } } }`,
|
||||||
|
).catch(() => null);
|
||||||
|
|
||||||
|
const agent = agentData?.agents?.edges?.[0]?.node;
|
||||||
|
if (!agent) throw new HttpException(`Agent ${agentId} not found in platform`, 404);
|
||||||
|
|
||||||
|
const password = agent.sipPassword ?? agent.sipExtension;
|
||||||
|
if (!password) throw new HttpException(`Agent ${agentId} has no sipPassword configured`, 400);
|
||||||
|
|
||||||
|
const sipId = agent.sipExtension;
|
||||||
|
if (!sipId) throw new HttpException(`Agent ${agentId} has no sipExtension configured`, 400);
|
||||||
|
|
||||||
|
this.logger.log(`[MAINT] Force ready: agent=${agentId} ext=${sipId}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.ozonetel.logoutAgent({ agentId, password });
|
await this.ozonetel.logoutAgent({ agentId, password });
|
||||||
@@ -46,9 +69,63 @@ export class MaintController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns the current per-agent session state — which ozonetelAgentIds
|
||||||
|
// are currently locked (held by a member IP) and which are free. Used
|
||||||
|
// by the maint OTP modal to render a picker so a supervisor can unlock
|
||||||
|
// the right agent without knowing the id off the top of their head.
|
||||||
|
// Read-only; OTP-guarded like the rest of /api/maint.
|
||||||
|
@Post('session-status')
|
||||||
|
async sessionStatus() {
|
||||||
|
const data = await this.platform.query<any>(
|
||||||
|
`{ agents(first: 100) { edges { node { id name ozonetelAgentId ozonetelDisplayName } } } }`,
|
||||||
|
).catch(() => ({ agents: { edges: [] } }));
|
||||||
|
|
||||||
|
const allAgents = (data?.agents?.edges ?? []).map((e: any) => e.node).filter((a: any) => a.ozonetelAgentId);
|
||||||
|
const sessions = await this.session.listLockedSessions();
|
||||||
|
const sessionByAgent = new Map(sessions.map((s) => [s.agentId.toLowerCase(), s]));
|
||||||
|
|
||||||
|
const locked: Array<any> = [];
|
||||||
|
const free: Array<any> = [];
|
||||||
|
const seenAgentIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const agent of allAgents) {
|
||||||
|
const key = String(agent.ozonetelAgentId).toLowerCase();
|
||||||
|
seenAgentIds.add(key);
|
||||||
|
const session = sessionByAgent.get(key);
|
||||||
|
const row = {
|
||||||
|
agentId: agent.ozonetelAgentId,
|
||||||
|
displayName: agent.name ?? agent.ozonetelDisplayName ?? agent.ozonetelAgentId,
|
||||||
|
};
|
||||||
|
if (session) {
|
||||||
|
locked.push({ ...row, heldByIp: session.ip, lockedAt: session.lockedAt });
|
||||||
|
} else {
|
||||||
|
free.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Surface orphan locks (Redis holds a session for an ozonetelAgentId
|
||||||
|
// with no matching Agent entity). Rare but possible after SDK renames
|
||||||
|
// or workspace resets — without surfacing them, the operator can't
|
||||||
|
// clear the stale lock via the UI.
|
||||||
|
for (const session of sessions) {
|
||||||
|
const key = session.agentId.toLowerCase();
|
||||||
|
if (!seenAgentIds.has(key)) {
|
||||||
|
locked.push({
|
||||||
|
agentId: session.agentId,
|
||||||
|
displayName: `${session.agentId} (orphan — no Agent record)`,
|
||||||
|
heldByIp: session.ip,
|
||||||
|
lockedAt: session.lockedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { locked, free };
|
||||||
|
}
|
||||||
|
|
||||||
@Post('unlock-agent')
|
@Post('unlock-agent')
|
||||||
async unlockAgent() {
|
async unlockAgent(@Body() body: { agentId: string }) {
|
||||||
const agentId = this.config.get<string>('OZONETEL_AGENT_ID') ?? 'agent3';
|
if (!body?.agentId) throw new HttpException('agentId required', 400);
|
||||||
|
const agentId = body.agentId;
|
||||||
this.logger.log(`[MAINT] Unlock agent session: ${agentId}`);
|
this.logger.log(`[MAINT] Unlock agent session: ${agentId}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -266,6 +343,7 @@ export class MaintController {
|
|||||||
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||||
{
|
{
|
||||||
data: {
|
data: {
|
||||||
|
name: `${firstName} ${lastName}`.trim(),
|
||||||
fullName: { firstName, lastName },
|
fullName: { firstName, lastName },
|
||||||
phones: { primaryPhoneNumber: `+91${phone}` },
|
phones: { primaryPhoneNumber: `+91${phone}` },
|
||||||
patientType: 'NEW',
|
patientType: 'NEW',
|
||||||
@@ -312,4 +390,692 @@ export class MaintController {
|
|||||||
this.logger.log(`[MAINT] Backfill complete: ${linked} linked, ${created} patients created, ${apptLinked} appointments linked, ${skipped} skipped`);
|
this.logger.log(`[MAINT] Backfill complete: ${linked} linked, ${created} patients created, ${apptLinked} appointments linked, ${skipped} skipped`);
|
||||||
return { status: 'ok', leads: { total: leads.length, linked, created, skipped }, appointments: { total: appointments.length, linked: apptLinked } };
|
return { status: 'ok', leads: { total: leads.length, linked, created, skipped }, appointments: { total: appointments.length, linked: apptLinked } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backfill Call records that lost their identity at ingest (missed-call
|
||||||
|
// webhook / poller / dispose flow before the caller-resolution wiring).
|
||||||
|
// Routes each phone through CallerResolutionService so the same code
|
||||||
|
// path the live system uses also fixes historical rows. Idempotent —
|
||||||
|
// safe to re-run; only patches calls that are currently missing
|
||||||
|
// leadName / patientId / leadId.
|
||||||
|
@Post('backfill-caller-resolution')
|
||||||
|
async backfillCallerResolution() {
|
||||||
|
this.logger.log('[MAINT] Backfill caller resolution — patching Calls + Leads via resolver');
|
||||||
|
|
||||||
|
const apiKey = process.env.PLATFORM_API_KEY ?? '';
|
||||||
|
const auth = apiKey ? `Bearer ${apiKey}` : '';
|
||||||
|
if (!auth) throw new HttpException('PLATFORM_API_KEY not configured', 500);
|
||||||
|
|
||||||
|
let callsScanned = 0;
|
||||||
|
let callsPatched = 0;
|
||||||
|
let callsSkipped = 0;
|
||||||
|
let leadsResolved = 0;
|
||||||
|
let resolveErrors = 0;
|
||||||
|
|
||||||
|
// Phone → resolved cache so multiple calls from the same number
|
||||||
|
// only resolve once during this run.
|
||||||
|
const resolvedByPhone = new Map<string, { leadId: string; patientId: string; firstName: string; lastName: string }>();
|
||||||
|
|
||||||
|
// Page through all calls in chunks of 200. We're after rows where
|
||||||
|
// leadName is empty OR leadId is null OR patientId is missing.
|
||||||
|
let cursor: string | null = null;
|
||||||
|
let hasNext = true;
|
||||||
|
while (hasNext) {
|
||||||
|
const pageQuery = cursor
|
||||||
|
? `{ calls(first: 200, after: "${cursor}") { edges { cursor node { id leadId leadName callerNumber { primaryPhoneNumber } } } pageInfo { hasNextPage endCursor } } }`
|
||||||
|
: `{ calls(first: 200) { edges { cursor node { id leadId leadName callerNumber { primaryPhoneNumber } } } pageInfo { hasNextPage endCursor } } }`;
|
||||||
|
let page: any;
|
||||||
|
try {
|
||||||
|
page = await this.platform.query<any>(pageQuery);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[MAINT] calls page query failed: ${err}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const edges = page?.calls?.edges ?? [];
|
||||||
|
hasNext = page?.calls?.pageInfo?.hasNextPage ?? false;
|
||||||
|
cursor = page?.calls?.pageInfo?.endCursor ?? null;
|
||||||
|
|
||||||
|
for (const edge of edges) {
|
||||||
|
const call = edge.node;
|
||||||
|
callsScanned++;
|
||||||
|
|
||||||
|
const phoneRaw = call.callerNumber?.primaryPhoneNumber ?? '';
|
||||||
|
const phone10 = phoneRaw.replace(/\D/g, '').slice(-10);
|
||||||
|
const needsName = !call.leadName || call.leadName === '';
|
||||||
|
const needsLead = !call.leadId;
|
||||||
|
|
||||||
|
if (!phone10 || phone10.length < 10) { callsSkipped++; continue; }
|
||||||
|
if (!needsName && !needsLead) { callsSkipped++; continue; }
|
||||||
|
|
||||||
|
let resolved = resolvedByPhone.get(phone10) ?? null;
|
||||||
|
if (!resolved) {
|
||||||
|
try {
|
||||||
|
const r = await this.callerResolution.resolve(phone10, auth);
|
||||||
|
resolved = {
|
||||||
|
leadId: r.leadId,
|
||||||
|
patientId: r.patientId,
|
||||||
|
firstName: r.firstName,
|
||||||
|
lastName: r.lastName,
|
||||||
|
};
|
||||||
|
resolvedByPhone.set(phone10, resolved);
|
||||||
|
leadsResolved++;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[MAINT] resolve failed for ${phone10}: ${err}`);
|
||||||
|
resolveErrors++;
|
||||||
|
callsSkipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullName = `${resolved.firstName} ${resolved.lastName}`.trim();
|
||||||
|
const updateParts: string[] = [];
|
||||||
|
if (needsLead && resolved.leadId) updateParts.push(`leadId: "${resolved.leadId}"`);
|
||||||
|
if (needsName && fullName) updateParts.push(`leadName: "${fullName.replace(/"/g, '\\"')}"`);
|
||||||
|
if (updateParts.length === 0) { callsSkipped++; continue; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation { updateCall(id: "${call.id}", data: { ${updateParts.join(', ')} }) { id } }`,
|
||||||
|
);
|
||||||
|
callsPatched++;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[MAINT] updateCall failed for ${call.id}: ${err}`);
|
||||||
|
callsSkipped++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throttle so the platform isn't hammered
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[MAINT] Backfill caller resolution complete: scanned=${callsScanned} patched=${callsPatched} skipped=${callsSkipped} uniquePhones=${resolvedByPhone.size} leadsResolved=${leadsResolved} resolveErrors=${resolveErrors}`);
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
calls: { scanned: callsScanned, patched: callsPatched, skipped: callsSkipped },
|
||||||
|
phones: { unique: resolvedByPhone.size, resolved: leadsResolved, errors: resolveErrors },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recompute durationS on existing AgentEvent rows using the per-category
|
||||||
|
// pairing logic. Fixes rows written before the slot-split fix where
|
||||||
|
// ACW_START clobbered CALL_START's pending entry. Also re-runs the
|
||||||
|
// session rollup for each affected date. Idempotent — only updates rows
|
||||||
|
// whose stored durationS differs from the recomputed value.
|
||||||
|
//
|
||||||
|
// POST /api/maint/backfill-agent-event-durations
|
||||||
|
// body: { date?: "YYYY-MM-DD" | "all" } — default today IST
|
||||||
|
@Post('backfill-agent-event-durations')
|
||||||
|
async backfillAgentEventDurations(@Body() body: { date?: string }) {
|
||||||
|
const target = body?.date ?? this.todayIst();
|
||||||
|
this.logger.log(`[MAINT] Backfill AgentEvent durations — target=${target}`);
|
||||||
|
|
||||||
|
// Pull events for the range. If "all", no filter; otherwise scope to the IST day.
|
||||||
|
let events = await this.fetchAgentEventsForBackfill(target);
|
||||||
|
if (events.length === 0) {
|
||||||
|
return { status: 'ok', scanned: 0, patched: 0, skipped: 0, dates: [] };
|
||||||
|
}
|
||||||
|
this.logger.log(`[MAINT] Fetched ${events.length} AgentEvent rows`);
|
||||||
|
|
||||||
|
// Group by agent, sort by eventAt ascending.
|
||||||
|
const byAgent = new Map<string, typeof events>();
|
||||||
|
for (const e of events) {
|
||||||
|
const k = e.agentId;
|
||||||
|
if (!k) continue;
|
||||||
|
if (!byAgent.has(k)) byAgent.set(k, []);
|
||||||
|
byAgent.get(k)!.push(e);
|
||||||
|
}
|
||||||
|
for (const list of byAgent.values()) {
|
||||||
|
list.sort((a, b) => new Date(a.eventAt).getTime() - new Date(b.eventAt).getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-category slot pairing, same logic as the live ingest.
|
||||||
|
const slotForStart = (t: AgentEventType): 'pause' | 'call' | 'acw' | null =>
|
||||||
|
t === 'PAUSE' ? 'pause' : t === 'CALL_START' ? 'call' : t === 'ACW_START' ? 'acw' : null;
|
||||||
|
const slotForEnd = (t: AgentEventType): 'pause' | 'call' | 'acw' | null =>
|
||||||
|
t === 'RESUME' ? 'pause' : t === 'CALL_END' ? 'call' : t === 'ACW_END' ? 'acw' : null;
|
||||||
|
|
||||||
|
let patched = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
const affectedDates = new Set<string>();
|
||||||
|
|
||||||
|
for (const [agentId, agentEvents] of byAgent) {
|
||||||
|
const pending: { pause?: number; call?: number; acw?: number } = {};
|
||||||
|
for (const e of agentEvents) {
|
||||||
|
const eventMs = new Date(e.eventAt).getTime();
|
||||||
|
const endSlot = slotForEnd(e.eventType);
|
||||||
|
const startSlot = slotForStart(e.eventType);
|
||||||
|
|
||||||
|
let computed: number | null = null;
|
||||||
|
|
||||||
|
if (endSlot) {
|
||||||
|
const at = pending[endSlot];
|
||||||
|
if (at !== undefined) {
|
||||||
|
computed = Math.max(0, Math.round((eventMs - at) / 1000));
|
||||||
|
delete pending[endSlot];
|
||||||
|
}
|
||||||
|
} else if (startSlot) {
|
||||||
|
pending[startSlot] = eventMs;
|
||||||
|
} else if (e.eventType === 'READY' || e.eventType === 'LOGOUT') {
|
||||||
|
delete pending.pause;
|
||||||
|
delete pending.call;
|
||||||
|
delete pending.acw;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only patch END events that now have a computed duration
|
||||||
|
// different from what's stored.
|
||||||
|
if (endSlot && computed !== null && computed !== (e.durationS ?? null)) {
|
||||||
|
try {
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation { updateAgentEvent(id: "${e.id}", data: { durationS: ${computed} }) { id } }`,
|
||||||
|
);
|
||||||
|
patched++;
|
||||||
|
const datePart = (e.eventAt ?? '').slice(0, 10);
|
||||||
|
if (datePart) affectedDates.add(datePart);
|
||||||
|
this.logger.log(`[MAINT] Patched AgentEvent ${e.id} ${e.eventType} agent=${agentId} ${e.durationS ?? 'null'}s → ${computed}s`);
|
||||||
|
await new Promise((r) => setTimeout(r, 80));
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[MAINT] Patch failed for ${e.id}: ${err}`);
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-run rollup for each affected date so AgentSession numbers update.
|
||||||
|
const dates = Array.from(affectedDates);
|
||||||
|
for (const d of dates) {
|
||||||
|
try {
|
||||||
|
await this.history.rollupSessions(d);
|
||||||
|
this.logger.log(`[MAINT] Rollup re-run for ${d}`);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[MAINT] Rollup failed for ${d}: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[MAINT] Backfill AgentEvent durations complete: scanned=${events.length} patched=${patched} skipped=${skipped} dates=${dates.join(',')}`);
|
||||||
|
return { status: 'ok', scanned: events.length, patched, skipped, dates };
|
||||||
|
}
|
||||||
|
|
||||||
|
private todayIst(): string {
|
||||||
|
const ist = new Date(Date.now() + 5.5 * 60 * 60 * 1000);
|
||||||
|
return ist.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchAgentEventsForBackfill(date: string): Promise<Array<{ id: string; eventType: AgentEventType; eventAt: string; durationS: number | null; agentId: string }>> {
|
||||||
|
const events: Array<{ id: string; eventType: AgentEventType; eventAt: string; durationS: number | null; agentId: string }> = [];
|
||||||
|
let after: string | null = null;
|
||||||
|
const rangeFilter = date === 'all'
|
||||||
|
? ''
|
||||||
|
: `, filter: { eventAt: { gte: "${date}T00:00:00+05:30", lte: "${date}T23:59:59+05:30" } }`;
|
||||||
|
|
||||||
|
for (let page = 0; page < 50; page++) {
|
||||||
|
const cursorArg: string = after ? `, after: "${after}"` : '';
|
||||||
|
const data: any = await this.platform.query<any>(
|
||||||
|
`{ agentEvents(first: 200${cursorArg}${rangeFilter}, orderBy: [{ eventAt: AscNullsLast }]) {
|
||||||
|
edges { node { id eventType eventAt durationS agentId } }
|
||||||
|
pageInfo { hasNextPage endCursor }
|
||||||
|
} }`,
|
||||||
|
);
|
||||||
|
const edges = data?.agentEvents?.edges ?? [];
|
||||||
|
for (const e of edges) events.push(e.node);
|
||||||
|
const pageInfo: { hasNextPage?: boolean; endCursor?: string } = data?.agentEvents?.pageInfo ?? {};
|
||||||
|
if (!pageInfo.hasNextPage) break;
|
||||||
|
after = pageInfo.endCursor ?? null;
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Historical enrichment: runs the same CDR-enrichment loop the cron runs,
|
||||||
|
// but kicks it off immediately and (optionally) widens the date window
|
||||||
|
// beyond "today + yesterday" up to the CDR API's 15-day limit.
|
||||||
|
//
|
||||||
|
// POST /api/maint/enrich-call-agents
|
||||||
|
// Headers: x-maint-otp: <OTP>
|
||||||
|
// Body: { days?: number } — default 2 (matches the cron); max 15
|
||||||
|
@Post('enrich-call-agents')
|
||||||
|
async enrichCallAgents(@Body() body: { days?: number }) {
|
||||||
|
const requestedDays = Math.max(1, Math.min(15, body?.days ?? 2));
|
||||||
|
this.logger.log(`[MAINT] Enrich call agents — days=${requestedDays}`);
|
||||||
|
|
||||||
|
// Call the enrichment service once per date, respecting the 2-req/min
|
||||||
|
// CDR rate limit. Each tick fetches one date's CDR (1 req) so we can
|
||||||
|
// iterate up to 2 dates per minute — enforce a 35s gap between dates.
|
||||||
|
const dates = this.recentDatesIst(requestedDays);
|
||||||
|
let totalScanned = 0;
|
||||||
|
let totalEnriched = 0;
|
||||||
|
let totalSkipped = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < dates.length; i++) {
|
||||||
|
const date = dates[i];
|
||||||
|
try {
|
||||||
|
const result = await this.enrichSingleDate(date);
|
||||||
|
totalScanned += result.scanned;
|
||||||
|
totalEnriched += result.enriched;
|
||||||
|
totalSkipped += result.skipped;
|
||||||
|
this.logger.log(`[MAINT] ${date} — scanned=${result.scanned} enriched=${result.enriched} skipped=${result.skipped}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`[MAINT] Enrich failed for ${date}: ${err?.message ?? err}`);
|
||||||
|
}
|
||||||
|
// Rate limiting: 35s between dates to stay under 2 req/min on CDR.
|
||||||
|
if (i < dates.length - 1) await new Promise((r) => setTimeout(r, 35_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[MAINT] Enrichment complete: scanned=${totalScanned} enriched=${totalEnriched} skipped=${totalSkipped} across ${dates.length} dates`);
|
||||||
|
return { status: 'ok', scanned: totalScanned, enriched: totalEnriched, skipped: totalSkipped, dates };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback backfill for historical Calls that pre-date UCID persistence.
|
||||||
|
// Can't join to CDR without UCID, so parse the agentName string (which
|
||||||
|
// may be a transfer chain "A -> B -> C"), take the final segment, and
|
||||||
|
// resolve to an Agent entity by name or ozonetelAgentId (case-insensitive).
|
||||||
|
//
|
||||||
|
// POST /api/maint/backfill-call-agents-by-name
|
||||||
|
// Headers: x-maint-otp: <OTP>
|
||||||
|
// Body: {}
|
||||||
|
@Post('backfill-call-agents-by-name')
|
||||||
|
async backfillCallAgentsByName() {
|
||||||
|
this.logger.log('[MAINT] Backfill call agents by name — matching agentName last-segment to Agent entity');
|
||||||
|
|
||||||
|
// Pull all active agents — cheap, cached at service level but we
|
||||||
|
// also need name → UUID maps for this pass. Three indexes:
|
||||||
|
// - ozonetelAgentId (e.g. "globalhealthx") — matches outbound dispose rows
|
||||||
|
// - ozonetelDisplayName (e.g. "Ganesh Bandi") — matches inbound webhook rows
|
||||||
|
// - platform Agent.name (e.g. "Ganesh Iyer") — last-resort fallback
|
||||||
|
const agentData = await this.platform.query<any>(
|
||||||
|
`{ agents(first: 100) { edges { node { id name ozonetelAgentId ozonetelDisplayName } } } }`,
|
||||||
|
);
|
||||||
|
const agentUuidByName = new Map<string, string>();
|
||||||
|
const agentUuidByOzonetelId = new Map<string, string>();
|
||||||
|
const agentUuidByDisplayName = new Map<string, string>();
|
||||||
|
for (const edge of agentData?.agents?.edges ?? []) {
|
||||||
|
const a = edge.node;
|
||||||
|
if (a.name) agentUuidByName.set(a.name.toLowerCase().trim(), a.id);
|
||||||
|
if (a.ozonetelAgentId) agentUuidByOzonetelId.set(a.ozonetelAgentId.toLowerCase().trim(), a.id);
|
||||||
|
if (a.ozonetelDisplayName) agentUuidByDisplayName.set(a.ozonetelDisplayName.toLowerCase().trim(), a.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let scanned = 0;
|
||||||
|
let patched = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let unmatched = 0;
|
||||||
|
const unmatchedSamples = new Set<string>();
|
||||||
|
|
||||||
|
// Paginate through all Calls with agentId=null and agentName set.
|
||||||
|
let after: string | null = null;
|
||||||
|
for (let page = 0; page < 50; page++) {
|
||||||
|
const cursorArg: string = after ? `, after: "${after}"` : '';
|
||||||
|
const data: any = await this.platform.query<any>(
|
||||||
|
`{ calls(first: 200${cursorArg}, filter: {
|
||||||
|
agentId: { is: NULL },
|
||||||
|
agentName: { is: NOT_NULL }
|
||||||
|
}) {
|
||||||
|
edges { node { id agentName } }
|
||||||
|
pageInfo { hasNextPage endCursor }
|
||||||
|
} }`,
|
||||||
|
).catch(() => ({ calls: { edges: [], pageInfo: {} } }));
|
||||||
|
const edges = data?.calls?.edges ?? [];
|
||||||
|
scanned += edges.length;
|
||||||
|
|
||||||
|
for (const edge of edges) {
|
||||||
|
const call = edge.node;
|
||||||
|
if (!call.agentName || call.agentName.trim() === '') { skipped++; continue; }
|
||||||
|
|
||||||
|
// Take the final hop of the transfer chain, trimmed.
|
||||||
|
const segments = call.agentName.split('->').map((s: string) => s.trim()).filter(Boolean);
|
||||||
|
const last = segments[segments.length - 1];
|
||||||
|
if (!last) { skipped++; continue; }
|
||||||
|
|
||||||
|
// Prefer ozonetelAgentId match (outbound rows store
|
||||||
|
// agentName=agentId); fall back to ozonetelDisplayName
|
||||||
|
// (inbound webhook rows store the Ozonetel display string);
|
||||||
|
// last-resort match on platform Agent.name.
|
||||||
|
const key = last.toLowerCase();
|
||||||
|
const uuid = agentUuidByOzonetelId.get(key)
|
||||||
|
?? agentUuidByDisplayName.get(key)
|
||||||
|
?? agentUuidByName.get(key);
|
||||||
|
if (!uuid) {
|
||||||
|
unmatched++;
|
||||||
|
if (unmatchedSamples.size < 10) unmatchedSamples.add(last);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the raw chain on transferredTo if it was actually chained,
|
||||||
|
// so the audit trail is preserved even without CDR data.
|
||||||
|
const patchData: Record<string, any> = { agentId: uuid };
|
||||||
|
if (segments.length > 1) patchData.transferredTo = call.agentName;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: call.id, data: patchData },
|
||||||
|
);
|
||||||
|
patched++;
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
} catch (err) {
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageInfo = data?.calls?.pageInfo ?? {};
|
||||||
|
if (!pageInfo.hasNextPage) break;
|
||||||
|
after = pageInfo.endCursor ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[MAINT] Backfill by name complete: scanned=${scanned} patched=${patched} unmatched=${unmatched} skipped=${skipped}`);
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
scanned,
|
||||||
|
patched,
|
||||||
|
unmatched,
|
||||||
|
skipped,
|
||||||
|
unmatchedSamples: Array.from(unmatchedSamples),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enrichSingleDate(date: string): Promise<{ scanned: number; enriched: number; skipped: number }> {
|
||||||
|
// Reuse the cdr-enrichment path via its runOnce method, but scoped.
|
||||||
|
// For simplicity we reimplement the single-date logic here so we can
|
||||||
|
// parameterize the date without leaking CDR-enrichment internals.
|
||||||
|
const cdrRows = await this.ozonetel.fetchCDR({ date });
|
||||||
|
if (cdrRows.length === 0) return { scanned: 0, enriched: 0, skipped: 0 };
|
||||||
|
|
||||||
|
const byUcid = new Map<string, any>();
|
||||||
|
for (const row of cdrRows) {
|
||||||
|
const ucid = String(row.UCID ?? '').trim();
|
||||||
|
if (ucid) byUcid.set(ucid, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch calls missing agent link on this date
|
||||||
|
const gte = `${date}T00:00:00+05:30`;
|
||||||
|
const lte = `${date}T23:59:59+05:30`;
|
||||||
|
const calls: Array<any> = [];
|
||||||
|
let after: string | null = null;
|
||||||
|
for (let page = 0; page < 30; page++) {
|
||||||
|
const cursorArg: string = after ? `, after: "${after}"` : '';
|
||||||
|
const data: any = await this.platform.query<any>(
|
||||||
|
`{ calls(first: 200${cursorArg}, filter: {
|
||||||
|
startedAt: { gte: "${gte}", lte: "${lte}" },
|
||||||
|
ucid: { is: NOT_NULL },
|
||||||
|
agentId: { is: NULL }
|
||||||
|
}) {
|
||||||
|
edges { node { id ucid agentId transferredTo transferType } }
|
||||||
|
pageInfo { hasNextPage endCursor }
|
||||||
|
} }`,
|
||||||
|
).catch(() => ({ calls: { edges: [], pageInfo: {} } }));
|
||||||
|
const edges = data?.calls?.edges ?? [];
|
||||||
|
for (const e of edges) calls.push(e.node);
|
||||||
|
const pageInfo = data?.calls?.pageInfo ?? {};
|
||||||
|
if (!pageInfo.hasNextPage) break;
|
||||||
|
after = pageInfo.endCursor ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let enriched = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
for (const call of calls) {
|
||||||
|
const cdrRow = byUcid.get(String(call.ucid).trim());
|
||||||
|
if (!cdrRow) { skipped++; continue; }
|
||||||
|
const patch: Record<string, any> = {};
|
||||||
|
if (cdrRow.AgentID && !call.agentId) {
|
||||||
|
const uuid = await this.agentLookup.resolveByOzonetelId(cdrRow.AgentID);
|
||||||
|
if (uuid) patch.agentId = uuid;
|
||||||
|
if (cdrRow.AgentName) patch.agentName = cdrRow.AgentName;
|
||||||
|
}
|
||||||
|
if (cdrRow.TransferredTo && !call.transferredTo) patch.transferredTo = cdrRow.TransferredTo;
|
||||||
|
if (cdrRow.TransferType && !call.transferType) patch.transferType = cdrRow.TransferType;
|
||||||
|
|
||||||
|
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 },
|
||||||
|
);
|
||||||
|
enriched++;
|
||||||
|
await new Promise((r) => setTimeout(r, 80));
|
||||||
|
} catch (err) {
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { scanned: calls.length, enriched, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
private recentDatesIst(n: number): string[] {
|
||||||
|
const dates: string[] = [];
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const d = new Date(Date.now() + 5.5 * 60 * 60 * 1000 - i * 24 * 60 * 60 * 1000);
|
||||||
|
dates.push(d.toISOString().slice(0, 10));
|
||||||
|
}
|
||||||
|
return dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Infer clinicId on historical Appointments that were written before
|
||||||
|
// the clinicId-persistence fix went live. Lookup path:
|
||||||
|
// Appointment.doctorId + Appointment.scheduledAt.dayOfWeek
|
||||||
|
// → DoctorVisitSlot rows for that doctor on that weekday
|
||||||
|
// → if single clinic → use it
|
||||||
|
// → if multiple clinics → match by time-of-day window (slot covers scheduledAt time)
|
||||||
|
// → if still ambiguous → match by department, else skip
|
||||||
|
//
|
||||||
|
// POST /api/maint/backfill-appointment-clinics
|
||||||
|
// Headers: x-maint-otp: <OTP>
|
||||||
|
@Post('backfill-appointment-clinics')
|
||||||
|
async backfillAppointmentClinics() {
|
||||||
|
this.logger.log('[MAINT] Backfill Appointment.clinicId — inferring from doctorVisitSlots');
|
||||||
|
|
||||||
|
// 1. Pull all appointments missing clinicId
|
||||||
|
const appointments: Array<{ id: string; doctorId: string | null; scheduledAt: string | null; department: string | null }> = [];
|
||||||
|
let after: string | null = null;
|
||||||
|
for (let page = 0; page < 50; page++) {
|
||||||
|
const cursor: string = after ? `, after: "${after}"` : '';
|
||||||
|
const data: any = await this.platform.query<any>(
|
||||||
|
`{ appointments(first: 200${cursor}, filter: { clinicId: { is: NULL } }) {
|
||||||
|
edges { node { id doctorId scheduledAt department } }
|
||||||
|
pageInfo { hasNextPage endCursor }
|
||||||
|
} }`,
|
||||||
|
).catch(() => ({ appointments: { edges: [], pageInfo: {} } }));
|
||||||
|
const edges = data?.appointments?.edges ?? [];
|
||||||
|
for (const e of edges) appointments.push(e.node);
|
||||||
|
const info = data?.appointments?.pageInfo ?? {};
|
||||||
|
if (!info.hasNextPage) break;
|
||||||
|
after = info.endCursor ?? null;
|
||||||
|
}
|
||||||
|
this.logger.log(`[MAINT] Found ${appointments.length} appointments missing clinicId`);
|
||||||
|
if (appointments.length === 0) {
|
||||||
|
return { status: 'ok', scanned: 0, patched: 0, skipped: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. For each unique doctorId, pre-load visit slots (7 weekdays × clinic rows).
|
||||||
|
const uniqueDoctorIds = [...new Set(appointments.map((a) => a.doctorId).filter(Boolean) as string[])];
|
||||||
|
const slotsByDoctor = new Map<string, Array<{ dayOfWeek: string; startTime: string; endTime: string; clinicId: string; clinicName: string }>>();
|
||||||
|
for (const docId of uniqueDoctorIds) {
|
||||||
|
try {
|
||||||
|
const data: any = await this.platform.query<any>(
|
||||||
|
`{ doctorVisitSlots(first: 50, filter: { doctorId: { eq: "${docId}" } }) {
|
||||||
|
edges { node { dayOfWeek startTime endTime clinic { id clinicName } } }
|
||||||
|
} }`,
|
||||||
|
);
|
||||||
|
const rows = (data?.doctorVisitSlots?.edges ?? []).map((e: any) => ({
|
||||||
|
dayOfWeek: e.node.dayOfWeek,
|
||||||
|
startTime: e.node.startTime,
|
||||||
|
endTime: e.node.endTime,
|
||||||
|
clinicId: e.node.clinic?.id,
|
||||||
|
clinicName: e.node.clinic?.clinicName ?? '',
|
||||||
|
})).filter((r: any) => r.clinicId);
|
||||||
|
slotsByDoctor.set(docId, rows);
|
||||||
|
} catch {
|
||||||
|
slotsByDoctor.set(docId, []);
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Walk each appointment, infer the clinic, patch.
|
||||||
|
let patched = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
const skippedReasons: Record<string, number> = { noDoctor: 0, noScheduledAt: 0, noSlots: 0, ambiguous: 0 };
|
||||||
|
|
||||||
|
for (const appt of appointments) {
|
||||||
|
if (!appt.doctorId) { skipped++; skippedReasons.noDoctor++; continue; }
|
||||||
|
if (!appt.scheduledAt) { skipped++; skippedReasons.noScheduledAt++; continue; }
|
||||||
|
|
||||||
|
const slots = slotsByDoctor.get(appt.doctorId) ?? [];
|
||||||
|
if (slots.length === 0) { skipped++; skippedReasons.noSlots++; continue; }
|
||||||
|
|
||||||
|
// Appointment time in IST
|
||||||
|
const ist = new Date(new Date(appt.scheduledAt).getTime() + 5.5 * 60 * 60 * 1000);
|
||||||
|
const dayOfWeek = ist.toLocaleDateString('en-US', { weekday: 'long', timeZone: 'UTC' }).toUpperCase();
|
||||||
|
const apptMinutes = ist.getUTCHours() * 60 + ist.getUTCMinutes();
|
||||||
|
|
||||||
|
// Match slots for same weekday where the appointment time falls within the window
|
||||||
|
const toMin = (hhmm: string): number => {
|
||||||
|
const [h, m] = hhmm.split(':').map(Number);
|
||||||
|
return h * 60 + (m ?? 0);
|
||||||
|
};
|
||||||
|
let candidates = slots.filter((s) => s.dayOfWeek === dayOfWeek);
|
||||||
|
if (candidates.length > 0) {
|
||||||
|
const inWindow = candidates.filter((s) => {
|
||||||
|
const start = toMin(s.startTime ?? '00:00');
|
||||||
|
const end = toMin(s.endTime ?? '23:59');
|
||||||
|
return apptMinutes >= start && apptMinutes < end;
|
||||||
|
});
|
||||||
|
if (inWindow.length > 0) candidates = inWindow;
|
||||||
|
}
|
||||||
|
// Distinct clinics among candidates
|
||||||
|
const distinctClinics = [...new Set(candidates.map((c) => c.clinicId))];
|
||||||
|
let clinicId: string | null = null;
|
||||||
|
if (distinctClinics.length === 1) {
|
||||||
|
clinicId = distinctClinics[0];
|
||||||
|
} else if (distinctClinics.length > 1) {
|
||||||
|
// Ambiguous — doctor visits multiple clinics in this window.
|
||||||
|
// Pick deterministically by clinic id lex-order so re-runs land
|
||||||
|
// on the same choice. Log the ambiguity so QA can review.
|
||||||
|
clinicId = [...distinctClinics].sort()[0];
|
||||||
|
this.logger.debug(`[MAINT] Ambiguous clinic for appt=${appt.id} — doctor=${appt.doctorId} day=${dayOfWeek} candidates=${distinctClinics.join(',')} picked=${clinicId}`);
|
||||||
|
}
|
||||||
|
// Last resort: any clinic for that doctor (pick first)
|
||||||
|
if (!clinicId && slots.length > 0) clinicId = slots[0].clinicId;
|
||||||
|
|
||||||
|
if (!clinicId) { skipped++; skippedReasons.ambiguous++; continue; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation($id: UUID!, $data: AppointmentUpdateInput!) { updateAppointment(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: appt.id, data: { clinicId } },
|
||||||
|
);
|
||||||
|
patched++;
|
||||||
|
await new Promise((r) => setTimeout(r, 40));
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`[MAINT] updateAppointment(${appt.id}) failed: ${err?.message ?? err}`);
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
45
src/masterdata/masterdata.controller.ts
Normal file
45
src/masterdata/masterdata.controller.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { Controller, Get, Query, Logger } from '@nestjs/common';
|
||||||
|
import { MasterdataService } from './masterdata.service';
|
||||||
|
|
||||||
|
@Controller('api/masterdata')
|
||||||
|
export class MasterdataController {
|
||||||
|
private readonly logger = new Logger(MasterdataController.name);
|
||||||
|
|
||||||
|
constructor(private masterdata: MasterdataService) {}
|
||||||
|
|
||||||
|
@Get('departments')
|
||||||
|
async departments() {
|
||||||
|
return this.masterdata.getDepartments();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('doctors')
|
||||||
|
async doctors() {
|
||||||
|
return this.masterdata.getDoctors();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('clinics')
|
||||||
|
async clinics() {
|
||||||
|
return this.masterdata.getClinics();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Available time slots for a doctor on a given date.
|
||||||
|
// Computed from DoctorVisitSlot entities (doctor × clinic × dayOfWeek).
|
||||||
|
// Returns 30-min slots within the doctor's visiting window for that day.
|
||||||
|
//
|
||||||
|
// GET /api/masterdata/slots?doctorId=xxx&date=2026-04-15
|
||||||
|
@Get('slots')
|
||||||
|
async slots(
|
||||||
|
@Query('doctorId') doctorId: string,
|
||||||
|
@Query('date') date: string,
|
||||||
|
) {
|
||||||
|
if (!doctorId || !date) return [];
|
||||||
|
return this.masterdata.getAvailableSlots(doctorId, date);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force cache refresh (admin use)
|
||||||
|
@Get('refresh')
|
||||||
|
async refresh() {
|
||||||
|
await this.masterdata.invalidateAll();
|
||||||
|
return { refreshed: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/masterdata/masterdata.module.ts
Normal file
13
src/masterdata/masterdata.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { MasterdataController } from './masterdata.controller';
|
||||||
|
import { MasterdataService } from './masterdata.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PlatformModule, AuthModule],
|
||||||
|
controllers: [MasterdataController],
|
||||||
|
providers: [MasterdataService],
|
||||||
|
exports: [MasterdataService],
|
||||||
|
})
|
||||||
|
export class MasterdataModule {}
|
||||||
213
src/masterdata/masterdata.service.ts
Normal file
213
src/masterdata/masterdata.service.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { SessionService } from '../auth/session.service';
|
||||||
|
|
||||||
|
// Master data: cached lookups for departments, doctors, clinics.
|
||||||
|
// Fetched from the platform on first request, cached in Redis with TTL.
|
||||||
|
// Frontend dropdowns use these instead of direct GraphQL queries.
|
||||||
|
|
||||||
|
const CACHE_TTL = 300; // 5 minutes
|
||||||
|
const KEY_DEPARTMENTS = 'masterdata:departments';
|
||||||
|
const KEY_DOCTORS = 'masterdata:doctors';
|
||||||
|
const KEY_CLINICS = 'masterdata:clinics';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MasterdataService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(MasterdataService.name);
|
||||||
|
private readonly apiKey: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private config: ConfigService,
|
||||||
|
private platform: PlatformGraphqlService,
|
||||||
|
private cache: SessionService,
|
||||||
|
) {
|
||||||
|
this.apiKey = this.config.get<string>('platform.apiKey') ?? process.env.PLATFORM_API_KEY ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
// Warm cache on startup
|
||||||
|
try {
|
||||||
|
await this.getDepartments();
|
||||||
|
await this.getDoctors();
|
||||||
|
await this.getClinics();
|
||||||
|
this.logger.log('Master data cache warmed');
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`Cache warm failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDepartments(): Promise<string[]> {
|
||||||
|
const cached = await this.cache.getCache(KEY_DEPARTMENTS);
|
||||||
|
if (cached) return JSON.parse(cached);
|
||||||
|
|
||||||
|
const auth = `Bearer ${this.apiKey}`;
|
||||||
|
const data = await this.platform.queryWithAuth<any>(
|
||||||
|
`{ doctors(first: 500) { edges { node { department } } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
);
|
||||||
|
|
||||||
|
const departments = Array.from(new Set(
|
||||||
|
data.doctors.edges
|
||||||
|
.map((e: any) => e.node.department)
|
||||||
|
.filter((d: string) => d && d.trim()),
|
||||||
|
)).sort() as string[];
|
||||||
|
|
||||||
|
await this.cache.setCache(KEY_DEPARTMENTS, JSON.stringify(departments), CACHE_TTL);
|
||||||
|
this.logger.log(`Cached ${departments.length} departments`);
|
||||||
|
return departments;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDoctors(): Promise<Array<{ id: string; name: string; department: string; qualifications: string }>> {
|
||||||
|
const cached = await this.cache.getCache(KEY_DOCTORS);
|
||||||
|
if (cached) return JSON.parse(cached);
|
||||||
|
|
||||||
|
const auth = `Bearer ${this.apiKey}`;
|
||||||
|
const data = await this.platform.queryWithAuth<any>(
|
||||||
|
`{ doctors(first: 500) { edges { node {
|
||||||
|
id name department qualifications specialty active
|
||||||
|
fullName { firstName lastName }
|
||||||
|
} } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
);
|
||||||
|
|
||||||
|
const doctors = data.doctors.edges
|
||||||
|
.map((e: any) => ({
|
||||||
|
id: e.node.id,
|
||||||
|
name: e.node.name ?? `${e.node.fullName?.firstName ?? ''} ${e.node.fullName?.lastName ?? ''}`.trim(),
|
||||||
|
department: e.node.department ?? '',
|
||||||
|
qualifications: e.node.qualifications ?? '',
|
||||||
|
specialty: e.node.specialty ?? '',
|
||||||
|
active: e.node.active ?? true,
|
||||||
|
}))
|
||||||
|
.filter((d: any) => d.active !== false);
|
||||||
|
|
||||||
|
await this.cache.setCache(KEY_DOCTORS, JSON.stringify(doctors), CACHE_TTL);
|
||||||
|
this.logger.log(`Cached ${doctors.length} doctors`);
|
||||||
|
return doctors;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getClinics(): Promise<Array<{ id: string; name: string; phone: string; address: string; opensAt: string; closesAt: string }>> {
|
||||||
|
const cached = await this.cache.getCache(KEY_CLINICS);
|
||||||
|
if (cached) return JSON.parse(cached);
|
||||||
|
|
||||||
|
const auth = `Bearer ${this.apiKey}`;
|
||||||
|
const data = await this.platform.queryWithAuth<any>(
|
||||||
|
`{ clinics(first: 50) { edges { node {
|
||||||
|
id clinicName status opensAt closesAt
|
||||||
|
phone { primaryPhoneNumber }
|
||||||
|
addressCustom { addressCity addressState }
|
||||||
|
} } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
);
|
||||||
|
|
||||||
|
const clinics = data.clinics.edges
|
||||||
|
.filter((e: any) => e.node.status !== 'INACTIVE')
|
||||||
|
.map((e: any) => ({
|
||||||
|
id: e.node.id,
|
||||||
|
name: e.node.clinicName ?? '',
|
||||||
|
phone: e.node.phone?.primaryPhoneNumber ?? '',
|
||||||
|
opensAt: e.node.opensAt ?? '08:00',
|
||||||
|
closesAt: e.node.closesAt ?? '20:00',
|
||||||
|
address: [e.node.addressCustom?.addressCity, e.node.addressCustom?.addressState].filter(Boolean).join(', '),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await this.cache.setCache(KEY_CLINICS, JSON.stringify(clinics), CACHE_TTL);
|
||||||
|
this.logger.log(`Cached ${clinics.length} clinics`);
|
||||||
|
return clinics;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Available time slots for a doctor on a given date.
|
||||||
|
// Reads DoctorVisitSlot entities for the matching dayOfWeek,
|
||||||
|
// then generates 30-min slots within each visiting window.
|
||||||
|
async getAvailableSlots(doctorId: string, date: string): Promise<Array<{ time: string; label: string; clinicId: string; clinicName: string }>> {
|
||||||
|
const dayOfWeek = new Date(date).toLocaleDateString('en-US', { weekday: 'long' }).toUpperCase();
|
||||||
|
const cacheKey = `masterdata:slots:${doctorId}:${dayOfWeek}`;
|
||||||
|
|
||||||
|
// Cache stores the UNFILTERED full-day slot list (keyed by dayOfWeek,
|
||||||
|
// so it's reusable across dates that fall on the same weekday). The
|
||||||
|
// "hide past slots on today" filter is applied AFTER cache read so it
|
||||||
|
// stays correct as real-time advances without cache churn.
|
||||||
|
const cached = await this.cache.getCache(cacheKey);
|
||||||
|
if (cached) return this.filterPastSlotsForToday(JSON.parse(cached), date);
|
||||||
|
|
||||||
|
const auth = `Bearer ${this.apiKey}`;
|
||||||
|
const data = await this.platform.queryWithAuth<any>(
|
||||||
|
`{ doctorVisitSlots(first: 100, filter: { doctorId: { eq: "${doctorId}" }, dayOfWeek: { eq: ${dayOfWeek} } }) {
|
||||||
|
edges { node { id startTime endTime clinic { id clinicName } } }
|
||||||
|
} }`,
|
||||||
|
undefined, auth,
|
||||||
|
);
|
||||||
|
|
||||||
|
const slots: Array<{ time: string; label: string; clinicId: string; clinicName: string }> = [];
|
||||||
|
|
||||||
|
for (const edge of data.doctorVisitSlots?.edges ?? []) {
|
||||||
|
const node = edge.node;
|
||||||
|
const clinicId = node.clinic?.id ?? '';
|
||||||
|
const clinicName = node.clinic?.clinicName ?? '';
|
||||||
|
const startTime = node.startTime ?? '09:00';
|
||||||
|
const endTime = node.endTime ?? '17:00';
|
||||||
|
|
||||||
|
// Generate 30-min slots within visiting window
|
||||||
|
const [startH, startM] = startTime.split(':').map(Number);
|
||||||
|
const [endH, endM] = endTime.split(':').map(Number);
|
||||||
|
let h = startH, m = startM ?? 0;
|
||||||
|
const endMin = endH * 60 + (endM ?? 0);
|
||||||
|
|
||||||
|
while (h * 60 + m < endMin) {
|
||||||
|
const hh = h.toString().padStart(2, '0');
|
||||||
|
const mm = m.toString().padStart(2, '0');
|
||||||
|
const ampm = h < 12 ? 'AM' : 'PM';
|
||||||
|
const displayH = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
||||||
|
slots.push({
|
||||||
|
time: `${hh}:${mm}`,
|
||||||
|
label: `${displayH}:${mm.toString().padStart(2, '0')} ${ampm} — ${clinicName}`,
|
||||||
|
clinicId,
|
||||||
|
clinicName,
|
||||||
|
});
|
||||||
|
m += 30;
|
||||||
|
if (m >= 60) { h++; m = 0; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by time
|
||||||
|
slots.sort((a, b) => a.time.localeCompare(b.time));
|
||||||
|
|
||||||
|
// Cache the full UNFILTERED list so reuse across dates (same dayOfWeek)
|
||||||
|
// doesn't mis-serve filtered data from an earlier date.
|
||||||
|
await this.cache.setCache(cacheKey, JSON.stringify(slots), CACHE_TTL);
|
||||||
|
this.logger.log(`Generated ${slots.length} slots for doctor ${doctorId} on ${dayOfWeek}`);
|
||||||
|
|
||||||
|
return this.filterPastSlotsForToday(slots, date);
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the requested date is today (IST), hide slots whose time has
|
||||||
|
// already passed (30-min buffer so we don't offer the impossible-to-keep
|
||||||
|
// "in 5 minutes" slot). Applies to both cache-hit and fresh fetch paths.
|
||||||
|
private filterPastSlotsForToday(
|
||||||
|
slots: Array<{ time: string; label: string; clinicId: string; clinicName: string }>,
|
||||||
|
date: string,
|
||||||
|
): Array<{ time: string; label: string; clinicId: string; clinicName: string }> {
|
||||||
|
const todayIst = new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Kolkata' });
|
||||||
|
if (date !== todayIst) return slots;
|
||||||
|
|
||||||
|
const nowHHMM = new Date().toLocaleTimeString('en-GB', {
|
||||||
|
timeZone: 'Asia/Kolkata', hour: '2-digit', minute: '2-digit',
|
||||||
|
});
|
||||||
|
const [nowH, nowM] = nowHHMM.split(':').map(Number);
|
||||||
|
const cutoff = nowH * 60 + nowM + 30; // 30-min buffer
|
||||||
|
const filtered = slots.filter((s) => {
|
||||||
|
const [h, m] = s.time.split(':').map(Number);
|
||||||
|
return h * 60 + m >= cutoff;
|
||||||
|
});
|
||||||
|
this.logger.log(`[SLOTS] Today filter: ${slots.length} → ${filtered.length} (now=${nowHHMM} IST, cutoff=${Math.floor(cutoff / 60)}:${String(cutoff % 60).padStart(2, '0')})`);
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
async invalidateAll(): Promise<void> {
|
||||||
|
await this.cache.setCache(KEY_DEPARTMENTS, '', 1);
|
||||||
|
await this.cache.setCache(KEY_DOCTORS, '', 1);
|
||||||
|
await this.cache.setCache(KEY_CLINICS, '', 1);
|
||||||
|
this.logger.log('Master data cache invalidated');
|
||||||
|
}
|
||||||
|
}
|
||||||
174
src/ozonetel/cdr-enrichment.service.ts
Normal file
174
src/ozonetel/cdr-enrichment.service.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { OzonetelAgentService } from './ozonetel-agent.service';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { AgentLookupService } from '../platform/agent-lookup.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Periodically pulls Ozonetel CDR (per-row, includes unique AgentID) and
|
||||||
|
* enriches Call records that were created from the missed-call webhook
|
||||||
|
* or outbound dispose without the authoritative agent relation.
|
||||||
|
*
|
||||||
|
* Runs every 30 minutes — well under Ozonetel's 2-req/min cap on the CDR
|
||||||
|
* endpoints (one fetch per workspace per tick = 2/hour).
|
||||||
|
*
|
||||||
|
* Pairs Call rows to CDR rows by `ucid`. Only patches Calls that are
|
||||||
|
* missing `agentId` / `transferredTo` / `transferType` — idempotent.
|
||||||
|
*/
|
||||||
|
const ENRICHMENT_INTERVAL_MS = 30 * 60 * 1000;
|
||||||
|
const ENRICHMENT_DATE_WINDOW_DAYS = 2; // today + yesterday in case late-arriving calls straddle IST midnight
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CdrEnrichmentService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(CdrEnrichmentService.name);
|
||||||
|
private timer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly ozonetel: OzonetelAgentService,
|
||||||
|
private readonly platform: PlatformGraphqlService,
|
||||||
|
private readonly agentLookup: AgentLookupService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
// Kick off after 60s so the sidecar isn't hammering platform during boot,
|
||||||
|
// then settle into the 30-min cadence.
|
||||||
|
setTimeout(() => {
|
||||||
|
this.runOnce().catch((err) => {
|
||||||
|
this.logger.warn(`[CDR-ENRICH] First run failed: ${err?.message ?? err}`);
|
||||||
|
});
|
||||||
|
}, 60_000);
|
||||||
|
this.timer = setInterval(() => {
|
||||||
|
this.runOnce().catch((err) => {
|
||||||
|
this.logger.warn(`[CDR-ENRICH] Tick failed: ${err?.message ?? err}`);
|
||||||
|
});
|
||||||
|
}, ENRICHMENT_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleDestroy() {
|
||||||
|
if (this.timer) clearInterval(this.timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async runOnce(): Promise<{ scanned: number; enriched: number; skipped: number }> {
|
||||||
|
let scanned = 0;
|
||||||
|
let enriched = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
// Walk the IST-date window. For each date, pull CDR + patch Calls.
|
||||||
|
// Sleep 35s between dates — Ozonetel caps CDR endpoints at 2 req/min
|
||||||
|
// and the dispose flow shares that budget (fetchCdrByUCID per outbound).
|
||||||
|
const dates = this.recentDatesIst(ENRICHMENT_DATE_WINDOW_DAYS);
|
||||||
|
for (let i = 0; i < dates.length; i++) {
|
||||||
|
const date = dates[i];
|
||||||
|
if (i > 0) await new Promise((r) => setTimeout(r, 35_000));
|
||||||
|
const cdrRows = await this.ozonetel.fetchCDR({ date }).catch(() => []);
|
||||||
|
if (cdrRows.length === 0) continue;
|
||||||
|
|
||||||
|
// Build UCID → cdr-row map so we can O(1) join per Call.
|
||||||
|
// Ozonetel emits two identifiers per call — `UCID` (caller-leg)
|
||||||
|
// and `monitorUCID` (agent-leg). The webhook stores `monitorUCID`,
|
||||||
|
// but the bulk CDR rows are keyed on caller-leg `UCID`. Index
|
||||||
|
// both so the lookup at line ~79 finds the row regardless of
|
||||||
|
// which side was persisted. Without this, transferred inbound
|
||||||
|
// calls never get their agent relation enriched.
|
||||||
|
const byUcid = new Map<string, any>();
|
||||||
|
for (const row of cdrRows) {
|
||||||
|
const ucid = String(row.UCID ?? '').trim();
|
||||||
|
const monitorUcid = String(row.monitorUCID ?? '').trim();
|
||||||
|
if (ucid) byUcid.set(ucid, row);
|
||||||
|
if (monitorUcid && monitorUcid !== ucid) byUcid.set(monitorUcid, row);
|
||||||
|
}
|
||||||
|
if (byUcid.size === 0) continue;
|
||||||
|
|
||||||
|
// Pull Calls in the same date window that are missing agent linkage
|
||||||
|
// (i.e. ucid set, agentId null). Patch each.
|
||||||
|
const calls = await this.fetchCallsMissingAgent(date);
|
||||||
|
scanned += calls.length;
|
||||||
|
|
||||||
|
for (const call of calls) {
|
||||||
|
const cdrRow = byUcid.get(String(call.ucid).trim());
|
||||||
|
if (!cdrRow) { skipped++; continue; }
|
||||||
|
|
||||||
|
const patch: Record<string, any> = {};
|
||||||
|
if (!call.agentId) {
|
||||||
|
// Primary resolution: use AgentID from CDR (unique lowercase id).
|
||||||
|
const cdrAgentId = cdrRow.AgentID;
|
||||||
|
let uuid = cdrAgentId
|
||||||
|
? await this.agentLookup.resolveByOzonetelId(cdrAgentId)
|
||||||
|
: null;
|
||||||
|
// Fallback: CDR AgentName may be a chain ("A -> B") for
|
||||||
|
// transferred calls. Pick the final handler (last segment)
|
||||||
|
// and look it up by display name or ozonetelId. Matches
|
||||||
|
// the write-time resolution in missed-call-webhook.
|
||||||
|
if (!uuid && cdrRow.AgentName) {
|
||||||
|
const segments = String(cdrRow.AgentName).split('->').map((s) => s.trim()).filter(Boolean);
|
||||||
|
const finalHandler = segments[segments.length - 1];
|
||||||
|
if (finalHandler) {
|
||||||
|
uuid =
|
||||||
|
(await this.agentLookup.resolveByOzonetelId(finalHandler)) ??
|
||||||
|
(await this.agentLookup.resolveByDisplayName(finalHandler));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (uuid) patch.agentId = uuid;
|
||||||
|
if (cdrRow.AgentName) patch.agentName = cdrRow.AgentName;
|
||||||
|
}
|
||||||
|
if (cdrRow.TransferredTo && !call.transferredTo) patch.transferredTo = cdrRow.TransferredTo;
|
||||||
|
if (cdrRow.TransferType && !call.transferType) patch.transferType = cdrRow.TransferType;
|
||||||
|
|
||||||
|
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 },
|
||||||
|
);
|
||||||
|
enriched++;
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`[CDR-ENRICH] Patch failed for ${call.id}: ${err?.message ?? err}`);
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scanned > 0 || enriched > 0) {
|
||||||
|
this.logger.log(`[CDR-ENRICH] Pass complete — dates=[${dates.join(',')}] scanned=${scanned} enriched=${enriched} skipped=${skipped}`);
|
||||||
|
}
|
||||||
|
return { scanned, enriched, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchCallsMissingAgent(date: string): Promise<Array<{ id: string; ucid: string | null; agentId: string | null; transferredTo: string | null; transferType: string | null }>> {
|
||||||
|
// Bound by IST day. CDR window is 15 days; we only ever need recent.
|
||||||
|
const gte = `${date}T00:00:00+05:30`;
|
||||||
|
const lte = `${date}T23:59:59+05:30`;
|
||||||
|
const results: Array<any> = [];
|
||||||
|
let after: string | null = null;
|
||||||
|
|
||||||
|
for (let page = 0; page < 20; page++) {
|
||||||
|
const cursorArg: string = after ? `, after: "${after}"` : '';
|
||||||
|
const data: any = await this.platform.query<any>(
|
||||||
|
`{ calls(first: 200${cursorArg}, filter: {
|
||||||
|
startedAt: { gte: "${gte}", lte: "${lte}" },
|
||||||
|
ucid: { is: NOT_NULL },
|
||||||
|
agentId: { is: NULL }
|
||||||
|
}) {
|
||||||
|
edges { node { id ucid agentId transferredTo transferType } }
|
||||||
|
pageInfo { hasNextPage endCursor }
|
||||||
|
} }`,
|
||||||
|
).catch(() => ({ calls: { edges: [], pageInfo: {} } }));
|
||||||
|
const edges = data?.calls?.edges ?? [];
|
||||||
|
for (const e of edges) results.push(e.node);
|
||||||
|
const pageInfo = data?.calls?.pageInfo ?? {};
|
||||||
|
if (!pageInfo.hasNextPage) break;
|
||||||
|
after = pageInfo.endCursor ?? null;
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private recentDatesIst(n: number): string[] {
|
||||||
|
const dates: string[] = [];
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const d = new Date(Date.now() + 5.5 * 60 * 60 * 1000 - i * 24 * 60 * 60 * 1000);
|
||||||
|
dates.push(d.toISOString().slice(0, 10));
|
||||||
|
}
|
||||||
|
return dates;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
import { Controller, Get, Query, Logger, Header } from '@nestjs/common';
|
import { Controller, Get, Query, Logger, Header } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||||
|
|
||||||
@Controller('kookoo')
|
@Controller('kookoo')
|
||||||
export class KookooIvrController {
|
export class KookooIvrController {
|
||||||
private readonly logger = new Logger(KookooIvrController.name);
|
private readonly logger = new Logger(KookooIvrController.name);
|
||||||
private readonly sipId: string;
|
|
||||||
private readonly callerId: string;
|
|
||||||
|
|
||||||
constructor(private config: ConfigService) {
|
constructor(private telephony: TelephonyConfigService) {}
|
||||||
this.sipId = process.env.OZONETEL_SIP_ID ?? '523590';
|
|
||||||
this.callerId = process.env.OZONETEL_DID ?? '918041763265';
|
private get sipId(): string {
|
||||||
|
return this.telephony.getConfig().ozonetel.sipId || '523590';
|
||||||
|
}
|
||||||
|
private get callerId(): string {
|
||||||
|
return this.telephony.getConfig().ozonetel.did || '918041763265';
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('ivr')
|
@Get('ivr')
|
||||||
|
|||||||
127
src/ozonetel/ozonetel-admin-auth.service.ts
Normal file
127
src/ozonetel/ozonetel-admin-auth.service.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { publicEncrypt, constants as cryptoConstants } from 'crypto';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||||
|
|
||||||
|
// Ozonetel admin API auth — login with RSA-encrypted credentials, cache JWT.
|
||||||
|
// Used by supervisor barge endpoints to call dashboardApi.
|
||||||
|
//
|
||||||
|
// Auth flow (from CA-Admin source code):
|
||||||
|
// 1. GET /api/auth/public-key → { publicKey, keyId }
|
||||||
|
// 2. RSA-encrypt username + password with publicKey
|
||||||
|
// 3. POST /auth/login → JWT token
|
||||||
|
// 4. All admin API calls use: Authorization: Bearer <jwt>, userId, userName, isSuperAdmin
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OzonetelAdminAuthService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(OzonetelAdminAuthService.name);
|
||||||
|
private cachedToken: string | null = null;
|
||||||
|
private cachedUserId: string | null = null;
|
||||||
|
private cachedUserName: string | null = null;
|
||||||
|
private tokenExpiresAt = 0;
|
||||||
|
|
||||||
|
constructor(private readonly telephony: TelephonyConfigService) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
const config = this.telephony.getConfig();
|
||||||
|
if (config.ozonetel.adminUsername && config.ozonetel.adminPassword) {
|
||||||
|
this.logger.log('Ozonetel admin credentials configured — will authenticate on first use');
|
||||||
|
} else {
|
||||||
|
this.logger.warn('Ozonetel admin credentials not configured — supervisor barge will be unavailable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private get apiBase(): string {
|
||||||
|
return 'https://api.cloudagent.ozonetel.com';
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAuthHeaders(): Promise<Record<string, string>> {
|
||||||
|
const token = await this.getToken();
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'userId': this.cachedUserId ?? '',
|
||||||
|
'userName': this.cachedUserName ?? '',
|
||||||
|
'isSuperAdmin': 'true',
|
||||||
|
'dAccessType': 'false',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getToken(): Promise<string> {
|
||||||
|
if (this.cachedToken && Date.now() < this.tokenExpiresAt) {
|
||||||
|
return this.cachedToken;
|
||||||
|
}
|
||||||
|
return this.login();
|
||||||
|
}
|
||||||
|
|
||||||
|
private rsaEncrypt(publicKeyRaw: string, plaintext: string): string {
|
||||||
|
// Ozonetel returns raw base64 without PEM headers — wrap it
|
||||||
|
const pem = publicKeyRaw.includes('-----BEGIN')
|
||||||
|
? publicKeyRaw
|
||||||
|
: `-----BEGIN PUBLIC KEY-----\n${publicKeyRaw}\n-----END PUBLIC KEY-----`;
|
||||||
|
const buffer = Buffer.from(plaintext, 'utf8');
|
||||||
|
const encrypted = publicEncrypt(
|
||||||
|
{ key: pem, padding: cryptoConstants.RSA_PKCS1_PADDING },
|
||||||
|
buffer,
|
||||||
|
);
|
||||||
|
return encrypted.toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async login(): Promise<string> {
|
||||||
|
const config = this.telephony.getConfig();
|
||||||
|
const { adminUsername, adminPassword } = config.ozonetel;
|
||||||
|
|
||||||
|
if (!adminUsername || !adminPassword) {
|
||||||
|
throw new Error('Ozonetel admin credentials not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Get RSA public key
|
||||||
|
this.logger.log('Fetching Ozonetel public key...');
|
||||||
|
const preLoginRes = await axios.get(`${this.apiBase}/api/auth/public-key`);
|
||||||
|
const { publicKey, keyId } = preLoginRes.data;
|
||||||
|
|
||||||
|
if (!publicKey || !keyId) {
|
||||||
|
throw new Error('Failed to get Ozonetel public key');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: RSA-encrypt credentials using Node crypto
|
||||||
|
const encryptedUsername = this.rsaEncrypt(publicKey, adminUsername);
|
||||||
|
const encryptedPassword = this.rsaEncrypt(publicKey, adminPassword);
|
||||||
|
|
||||||
|
// Step 3: Login
|
||||||
|
this.logger.log('Logging into Ozonetel admin portal...');
|
||||||
|
const loginRes = await axios.post(`${this.apiBase}/auth/login`, {
|
||||||
|
username: encryptedUsername,
|
||||||
|
password: encryptedPassword,
|
||||||
|
keyId,
|
||||||
|
ltype: 'PORTAL',
|
||||||
|
}, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = loginRes.data;
|
||||||
|
if (!data.token) {
|
||||||
|
throw new Error(`Ozonetel admin login failed: ${JSON.stringify(data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cachedToken = data.token;
|
||||||
|
this.cachedUserId = data.userId?.toString() ?? data.UserId?.toString() ?? '';
|
||||||
|
this.cachedUserName = data.name ?? adminUsername;
|
||||||
|
|
||||||
|
// Decode token expiry — fallback to 6 hours
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(Buffer.from(data.token.split('.')[1], 'base64').toString());
|
||||||
|
this.tokenExpiresAt = (payload.exp ?? 0) * 1000 - 60_000; // refresh 1 min early
|
||||||
|
} catch {
|
||||||
|
this.tokenExpiresAt = Date.now() + 6 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Ozonetel admin login successful (userId=${this.cachedUserId}, expires in ${Math.round((this.tokenExpiresAt - Date.now()) / 60000)}min)`);
|
||||||
|
return this.cachedToken!;
|
||||||
|
}
|
||||||
|
|
||||||
|
isConfigured(): boolean {
|
||||||
|
const config = this.telephony.getConfig();
|
||||||
|
return !!(config.ozonetel.adminUsername && config.ozonetel.adminPassword);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,29 +1,42 @@
|
|||||||
import { Controller, Post, Get, Body, Query, Logger, HttpException } from '@nestjs/common';
|
import { Controller, Post, Get, Body, Query, Logger, HttpException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { OzonetelAgentService } from './ozonetel-agent.service';
|
import { OzonetelAgentService } from './ozonetel-agent.service';
|
||||||
import { MissedQueueService } from '../worklist/missed-queue.service';
|
import { MissedQueueService } from '../worklist/missed-queue.service';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { AgentLookupService } from '../platform/agent-lookup.service';
|
||||||
import { EventBusService } from '../events/event-bus.service';
|
import { EventBusService } from '../events/event-bus.service';
|
||||||
import { Topics } from '../events/event-types';
|
import { Topics } from '../events/event-types';
|
||||||
|
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||||
|
import { SupervisorService } from '../supervisor/supervisor.service';
|
||||||
|
import { AgentHistoryService } from '../supervisor/agent-history.service';
|
||||||
|
|
||||||
|
// Convert Ozonetel "HH:MM:SS" (or null/empty) to integer seconds.
|
||||||
|
// Returns null when input is missing or all-zero.
|
||||||
|
function parseHmsToSec(raw: any): number | null {
|
||||||
|
if (!raw || typeof raw !== 'string') return null;
|
||||||
|
if (raw === '00:00:00') return null;
|
||||||
|
const parts = raw.split(':').map((p) => parseInt(p, 10));
|
||||||
|
if (parts.length !== 3 || parts.some((n) => isNaN(n))) return null;
|
||||||
|
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||||
|
}
|
||||||
|
|
||||||
@Controller('api/ozonetel')
|
@Controller('api/ozonetel')
|
||||||
export class OzonetelAgentController {
|
export class OzonetelAgentController {
|
||||||
private readonly logger = new Logger(OzonetelAgentController.name);
|
private readonly logger = new Logger(OzonetelAgentController.name);
|
||||||
private readonly defaultAgentId: string;
|
|
||||||
private readonly defaultAgentPassword: string;
|
|
||||||
|
|
||||||
private readonly defaultSipId: string;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly ozonetelAgent: OzonetelAgentService,
|
private readonly ozonetelAgent: OzonetelAgentService,
|
||||||
private readonly config: ConfigService,
|
private readonly telephony: TelephonyConfigService,
|
||||||
private readonly missedQueue: MissedQueueService,
|
private readonly missedQueue: MissedQueueService,
|
||||||
private readonly platform: PlatformGraphqlService,
|
private readonly platform: PlatformGraphqlService,
|
||||||
private readonly eventBus: EventBusService,
|
private readonly eventBus: EventBusService,
|
||||||
) {
|
private readonly supervisor: SupervisorService,
|
||||||
this.defaultAgentId = config.get<string>('OZONETEL_AGENT_ID') ?? 'agent3';
|
private readonly agentLookup: AgentLookupService,
|
||||||
this.defaultAgentPassword = config.get<string>('OZONETEL_AGENT_PASSWORD') ?? '';
|
private readonly agentHistory: AgentHistoryService,
|
||||||
this.defaultSipId = config.get<string>('OZONETEL_SIP_ID') ?? '521814';
|
) {}
|
||||||
|
|
||||||
|
private requireAgentId(agentId: string | undefined | null): string {
|
||||||
|
if (!agentId) throw new HttpException('agentId required', 400);
|
||||||
|
return agentId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('agent-login')
|
@Post('agent-login')
|
||||||
@@ -62,17 +75,18 @@ export class OzonetelAgentController {
|
|||||||
|
|
||||||
@Post('agent-state')
|
@Post('agent-state')
|
||||||
async agentState(
|
async agentState(
|
||||||
@Body() body: { state: 'Ready' | 'Pause'; pauseReason?: string },
|
@Body() body: { agentId: string; state: 'Ready' | 'Pause'; pauseReason?: string },
|
||||||
) {
|
) {
|
||||||
if (!body.state) {
|
if (!body.state) {
|
||||||
throw new HttpException('state required', 400);
|
throw new HttpException('state required', 400);
|
||||||
}
|
}
|
||||||
|
const agentId = this.requireAgentId(body.agentId);
|
||||||
|
|
||||||
this.logger.log(`[AGENT-STATE] ${this.defaultAgentId} → ${body.state} (${body.pauseReason ?? 'none'})`);
|
this.logger.log(`[AGENT-STATE] ${agentId} → ${body.state} (${body.pauseReason ?? 'none'})`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.ozonetelAgent.changeAgentState({
|
const result = await this.ozonetelAgent.changeAgentState({
|
||||||
agentId: this.defaultAgentId,
|
agentId,
|
||||||
state: body.state,
|
state: body.state,
|
||||||
pauseReason: body.pauseReason,
|
pauseReason: body.pauseReason,
|
||||||
});
|
});
|
||||||
@@ -81,7 +95,7 @@ export class OzonetelAgentController {
|
|||||||
// Auto-assign missed call when agent goes Ready
|
// Auto-assign missed call when agent goes Ready
|
||||||
if (body.state === 'Ready') {
|
if (body.state === 'Ready') {
|
||||||
try {
|
try {
|
||||||
const assigned = await this.missedQueue.assignNext(this.defaultAgentId);
|
const assigned = await this.missedQueue.assignNext(agentId);
|
||||||
if (assigned) {
|
if (assigned) {
|
||||||
this.logger.log(`[AGENT-STATE] Auto-assigned missed call ${assigned.id}`);
|
this.logger.log(`[AGENT-STATE] Auto-assigned missed call ${assigned.id}`);
|
||||||
return { ...result, assignedCall: assigned };
|
return { ...result, assignedCall: assigned };
|
||||||
@@ -107,10 +121,12 @@ export class OzonetelAgentController {
|
|||||||
@Body() body: {
|
@Body() body: {
|
||||||
ucid: string;
|
ucid: string;
|
||||||
disposition: string;
|
disposition: string;
|
||||||
|
agentId: string;
|
||||||
callerPhone?: string;
|
callerPhone?: string;
|
||||||
direction?: string;
|
direction?: string;
|
||||||
durationSec?: number;
|
durationSec?: number;
|
||||||
leadId?: string;
|
leadId?: string;
|
||||||
|
leadName?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
missedCallId?: string;
|
missedCallId?: string;
|
||||||
},
|
},
|
||||||
@@ -119,13 +135,17 @@ export class OzonetelAgentController {
|
|||||||
throw new HttpException('ucid and disposition required', 400);
|
throw new HttpException('ucid and disposition required', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const agentId = this.requireAgentId(body.agentId);
|
||||||
const ozonetelDisposition = this.mapToOzonetelDisposition(body.disposition);
|
const ozonetelDisposition = this.mapToOzonetelDisposition(body.disposition);
|
||||||
|
|
||||||
this.logger.log(`[DISPOSE] ucid=${body.ucid} disposition=${body.disposition} → ozonetel="${ozonetelDisposition}" agentId=${this.defaultAgentId} callerPhone=${body.callerPhone ?? 'none'} direction=${body.direction ?? 'unknown'} leadId=${body.leadId ?? 'none'}`);
|
// Cancel the ACW auto-dispose timer — the frontend submitted disposition
|
||||||
|
this.supervisor.cancelAcwTimer(agentId);
|
||||||
|
|
||||||
|
this.logger.log(`[DISPOSE] ucid=${body.ucid} disposition=${body.disposition} → ozonetel="${ozonetelDisposition}" agentId=${agentId} callerPhone=${body.callerPhone ?? 'none'} direction=${body.direction ?? 'unknown'} leadId=${body.leadId ?? 'none'}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.ozonetelAgent.setDisposition({
|
const result = await this.ozonetelAgent.setDisposition({
|
||||||
agentId: this.defaultAgentId,
|
agentId,
|
||||||
ucid: body.ucid,
|
ucid: body.ucid,
|
||||||
disposition: ozonetelDisposition,
|
disposition: ozonetelDisposition,
|
||||||
});
|
});
|
||||||
@@ -136,20 +156,121 @@ export class OzonetelAgentController {
|
|||||||
this.logger.error(`[DISPOSE] FAILED: ${message} ${responseData}`);
|
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) {
|
||||||
|
try {
|
||||||
|
const durationSec = body.durationSec ?? 0;
|
||||||
|
const endedAt = new Date().toISOString();
|
||||||
|
const startedAt = durationSec > 0
|
||||||
|
? new Date(Date.now() - durationSec * 1000).toISOString()
|
||||||
|
: endedAt;
|
||||||
|
const callData: Record<string, any> = {
|
||||||
|
name: `Outbound — ${body.callerPhone}`,
|
||||||
|
direction: 'OUTBOUND',
|
||||||
|
callStatus: 'COMPLETED',
|
||||||
|
callerNumber: { primaryPhoneNumber: `+91${body.callerPhone.replace(/^\+?91/, '')}` },
|
||||||
|
agentName: agentId,
|
||||||
|
durationSec,
|
||||||
|
disposition: body.disposition,
|
||||||
|
startedAt,
|
||||||
|
endedAt,
|
||||||
|
};
|
||||||
|
// Persist UCID so the CDR enrichment cron and backfill can
|
||||||
|
// resolve the authoritative agent relation even if the initial
|
||||||
|
// lookup misses.
|
||||||
|
if (body.ucid) callData.ucid = body.ucid;
|
||||||
|
// Resolve the agent relation from the logged-in agentId. For
|
||||||
|
// outbound, the dispatching agent IS the handler — no transfer.
|
||||||
|
const agentUuid = await this.agentLookup.resolveByOzonetelId(agentId);
|
||||||
|
if (agentUuid) callData.agentId = agentUuid;
|
||||||
|
if (body.leadId) callData.leadId = body.leadId;
|
||||||
|
if (body.leadName) callData.leadName = body.leadName;
|
||||||
|
|
||||||
|
const apiKey = process.env.PLATFORM_API_KEY;
|
||||||
|
if (apiKey) {
|
||||||
|
const result = await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($data: CallCreateInput!) { createCall(data: $data) { id } }`,
|
||||||
|
{ data: callData },
|
||||||
|
`Bearer ${apiKey}`,
|
||||||
|
);
|
||||||
|
this.logger.log(`[DISPOSE] Created outbound call record: ${result.createCall.id}`);
|
||||||
|
|
||||||
|
// Fetch recording URL from CDR after a delay (Ozonetel needs time to process)
|
||||||
|
const callId = result.createCall.id;
|
||||||
|
const ucid = body.ucid;
|
||||||
|
const dateStr = new Date().toISOString().split('T')[0];
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
// fetchCdrByUCID is the targeted lookup — Ozonetel resolves
|
||||||
|
// leg-pair UCIDs server-side, so the agent-facing UCID we
|
||||||
|
// hold reliably returns the call row and its CallAudio.
|
||||||
|
const record = await this.ozonetelAgent.fetchCdrByUCID({ date: dateStr, ucid });
|
||||||
|
const audioUrl = record?.CallAudio || record?.AudioFile;
|
||||||
|
// Compose a single update with recording + SLA timing
|
||||||
|
// fields. CDR exposes HandlingTime, WrapupDuration,
|
||||||
|
// HoldDuration as HH:MM:SS strings.
|
||||||
|
const updateData: Record<string, any> = {};
|
||||||
|
if (audioUrl) {
|
||||||
|
updateData.recording = { primaryLinkUrl: audioUrl, primaryLinkLabel: 'Recording' };
|
||||||
|
}
|
||||||
|
const handlingSec = parseHmsToSec(record?.HandlingTime);
|
||||||
|
const wrapupSec = parseHmsToSec(record?.WrapupDuration);
|
||||||
|
const holdSec = parseHmsToSec(record?.HoldDuration);
|
||||||
|
if (handlingSec !== null) updateData.handlingTimeS = handlingSec;
|
||||||
|
if (wrapupSec !== null) updateData.acwDurationS = wrapupSec;
|
||||||
|
if (holdSec !== null) updateData.holdDurationS = holdSec;
|
||||||
|
// Overwrite agent relation with CDR's AgentID (the
|
||||||
|
// actual final handler; may differ from the caller
|
||||||
|
// agentId if Ozonetel transferred the dial).
|
||||||
|
const cdrAgentId = record?.AgentID;
|
||||||
|
if (cdrAgentId) {
|
||||||
|
const cdrAgentUuid = await this.agentLookup.resolveByOzonetelId(cdrAgentId);
|
||||||
|
if (cdrAgentUuid) updateData.agentId = cdrAgentUuid;
|
||||||
|
if (record.AgentName) updateData.agentName = record.AgentName;
|
||||||
|
}
|
||||||
|
if (record?.TransferredTo) updateData.transferredTo = record.TransferredTo;
|
||||||
|
if (record?.TransferType) updateData.transferType = record.TransferType;
|
||||||
|
if (Object.keys(updateData).length > 0) {
|
||||||
|
await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: callId, data: updateData },
|
||||||
|
`Bearer ${apiKey}`,
|
||||||
|
);
|
||||||
|
this.logger.log(`[DISPOSE] Updated outbound call ${callId} ${audioUrl ? 'with recording + ' : ''}timing (handling=${handlingSec ?? 'na'}s wrap=${wrapupSec ?? 'na'}s hold=${holdSec ?? 'na'}s)`);
|
||||||
|
} else {
|
||||||
|
this.logger.warn(`[DISPOSE] No CallAudio or timing for ucid=${ucid} — record=${JSON.stringify(record ?? null)}`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`[DISPOSE] Failed to fetch recording for outbound call: ${err.message}`);
|
||||||
|
}
|
||||||
|
}, 30_000);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`[DISPOSE] Failed to create outbound call record: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle missed call callback status update
|
// Handle missed call callback status update
|
||||||
if (body.missedCallId) {
|
if (body.missedCallId) {
|
||||||
const statusMap: Record<string, string> = {
|
const statusMap: Record<string, string> = {
|
||||||
APPOINTMENT_BOOKED: 'CALLBACK_COMPLETED',
|
APPOINTMENT_BOOKED: 'CALLBACK_COMPLETED',
|
||||||
|
APPOINTMENT_RESCHEDULED: 'CALLBACK_COMPLETED',
|
||||||
|
APPOINTMENT_CANCELLED: 'CALLBACK_COMPLETED',
|
||||||
INFO_PROVIDED: 'CALLBACK_COMPLETED',
|
INFO_PROVIDED: 'CALLBACK_COMPLETED',
|
||||||
FOLLOW_UP_SCHEDULED: 'CALLBACK_COMPLETED',
|
FOLLOW_UP_SCHEDULED: 'CALLBACK_COMPLETED',
|
||||||
CALLBACK_REQUESTED: 'CALLBACK_COMPLETED',
|
CALLBACK_REQUESTED: 'CALLBACK_COMPLETED',
|
||||||
|
NOT_INTERESTED: 'CALLBACK_COMPLETED',
|
||||||
WRONG_NUMBER: 'WRONG_NUMBER',
|
WRONG_NUMBER: 'WRONG_NUMBER',
|
||||||
|
NO_ANSWER: 'CALLBACK_ATTEMPTED',
|
||||||
};
|
};
|
||||||
const newStatus = statusMap[body.disposition];
|
const newStatus = statusMap[body.disposition];
|
||||||
if (newStatus) {
|
if (newStatus) {
|
||||||
try {
|
try {
|
||||||
await this.platform.query<any>(
|
await this.platform.query<any>(
|
||||||
`mutation { updateCall(id: "${body.missedCallId}", data: { callbackstatus: ${newStatus} }) { id } }`,
|
`mutation { updateCall(id: "${body.missedCallId}", data: { callbackStatus: ${newStatus}, disposition: ${body.disposition} }) { id } }`,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Failed to update missed call status: ${err}`);
|
this.logger.warn(`Failed to update missed call status: ${err}`);
|
||||||
@@ -157,9 +278,37 @@ 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
|
// Auto-assign next missed call to this agent
|
||||||
try {
|
try {
|
||||||
await this.missedQueue.assignNext(this.defaultAgentId);
|
await this.missedQueue.assignNext(agentId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Auto-assignment after dispose failed: ${err}`);
|
this.logger.warn(`Auto-assignment after dispose failed: ${err}`);
|
||||||
}
|
}
|
||||||
@@ -168,7 +317,7 @@ export class OzonetelAgentController {
|
|||||||
this.eventBus.emit(Topics.CALL_COMPLETED, {
|
this.eventBus.emit(Topics.CALL_COMPLETED, {
|
||||||
callId: null,
|
callId: null,
|
||||||
ucid: body.ucid,
|
ucid: body.ucid,
|
||||||
agentId: this.defaultAgentId,
|
agentId,
|
||||||
callerPhone: body.callerPhone ?? '',
|
callerPhone: body.callerPhone ?? '',
|
||||||
direction: body.direction ?? 'INBOUND',
|
direction: body.direction ?? 'INBOUND',
|
||||||
durationSec: body.durationSec ?? 0,
|
durationSec: body.durationSec ?? 0,
|
||||||
@@ -183,19 +332,27 @@ export class OzonetelAgentController {
|
|||||||
|
|
||||||
@Post('dial')
|
@Post('dial')
|
||||||
async dial(
|
async dial(
|
||||||
@Body() body: { phoneNumber: string; campaignName?: string; leadId?: string },
|
@Body() body: { phoneNumber: string; agentId: string; campaignName?: string; leadId?: string },
|
||||||
) {
|
) {
|
||||||
if (!body.phoneNumber) {
|
if (!body.phoneNumber) {
|
||||||
throw new HttpException('phoneNumber required', 400);
|
throw new HttpException('phoneNumber required', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const campaignName = body.campaignName ?? process.env.OZONETEL_CAMPAIGN_NAME ?? 'Inbound_918041763265';
|
const agentId = this.requireAgentId(body.agentId);
|
||||||
|
const did = this.telephony.getConfig().ozonetel.did;
|
||||||
|
const campaignName = body.campaignName
|
||||||
|
|| this.telephony.getConfig().ozonetel.campaignName
|
||||||
|
|| (did ? `Inbound_${did}` : '');
|
||||||
|
|
||||||
this.logger.log(`[DIAL] phone=${body.phoneNumber} campaign=${campaignName} agentId=${this.defaultAgentId} lead=${body.leadId ?? 'none'}`);
|
if (!campaignName) {
|
||||||
|
throw new HttpException('Campaign name not configured — set in Telephony settings or pass campaignName', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[DIAL] phone=${body.phoneNumber} campaign=${campaignName} agentId=${agentId} lead=${body.leadId ?? 'none'}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.ozonetelAgent.manualDial({
|
const result = await this.ozonetelAgent.manualDial({
|
||||||
agentId: this.defaultAgentId,
|
agentId,
|
||||||
campaignName,
|
campaignName,
|
||||||
customerNumber: body.phoneNumber,
|
customerNumber: body.phoneNumber,
|
||||||
});
|
});
|
||||||
@@ -225,6 +382,13 @@ export class OzonetelAgentController {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.ozonetelAgent.callControl(body);
|
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;
|
return result;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const message = error.response?.data?.message ?? error.message ?? 'Call control failed';
|
const message = error.response?.data?.message ?? error.message ?? 'Call control failed';
|
||||||
@@ -273,23 +437,56 @@ export class OzonetelAgentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('performance')
|
@Get('performance')
|
||||||
async performance(@Query('date') date?: string) {
|
async performance(@Query('date') date?: string, @Query('agentId') agentId?: string) {
|
||||||
|
const agent = this.requireAgentId(agentId);
|
||||||
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
||||||
this.logger.log(`Performance: date=${targetDate} agent=${this.defaultAgentId}`);
|
this.logger.log(`Performance: date=${targetDate} agent=${agent}`);
|
||||||
|
|
||||||
const [cdr, summary, aht] = await Promise.all([
|
// Trigger an on-demand rollup for the requested date so the
|
||||||
|
// AgentSession row reflects the current open session (caps at now)
|
||||||
|
// instead of waiting up to 15 min for the background tick. Fire-and-
|
||||||
|
// forget with a short await so we don't block the whole response on
|
||||||
|
// cache-refresh tail but still hand the read a fresh row when Redpanda
|
||||||
|
// is quiet. Safe to error — AgentSession just stays stale.
|
||||||
|
await this.agentHistory.rollupSessions(targetDate).catch(() => {});
|
||||||
|
|
||||||
|
const [cdr, summary, aht, agentSessionBreakdown] = await Promise.all([
|
||||||
this.ozonetelAgent.fetchCDR({ date: targetDate }),
|
this.ozonetelAgent.fetchCDR({ date: targetDate }),
|
||||||
this.ozonetelAgent.getAgentSummary(this.defaultAgentId, targetDate),
|
this.ozonetelAgent.getAgentSummary(agent, targetDate),
|
||||||
this.ozonetelAgent.getAHT(this.defaultAgentId),
|
this.ozonetelAgent.getAHT(agent),
|
||||||
|
this.fetchAgentSessionTimeBreakdown(agent, targetDate),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const totalCalls = cdr.length;
|
// Prefer our AgentSession rollup when present — it correctly counts
|
||||||
const inbound = cdr.filter((c: any) => c.Type === 'InBound').length;
|
// the current OPEN session (caps at now), while Ozonetel's summaryReport
|
||||||
const outbound = cdr.filter((c: any) => c.Type === 'Manual' || c.Type === 'Progressive').length;
|
// only tallies CLOSED login→logout pairs. Fall back to Ozonetel if
|
||||||
const answered = cdr.filter((c: any) => c.Status === 'Answered').length;
|
// our rollup hasn't captured this agent yet (e.g., brand-new agent,
|
||||||
const missed = cdr.filter((c: any) => c.Status === 'Unanswered' || c.Status === 'NotAnswered').length;
|
// workspace without AgentEvent entity synced).
|
||||||
|
const timeUtilization = agentSessionBreakdown ?? summary;
|
||||||
|
|
||||||
const talkTimes = cdr
|
// Filter CDR to this agent only — fetchCDR returns all agents' calls
|
||||||
|
// Use case-insensitive matching — Ozonetel field casing varies
|
||||||
|
const agentLower = agent.toLowerCase();
|
||||||
|
const agentCdr = cdr.filter((c: any) =>
|
||||||
|
(c.AgentID ?? '').toLowerCase() === agentLower ||
|
||||||
|
(c.AgentName ?? '').toLowerCase() === agentLower,
|
||||||
|
);
|
||||||
|
this.logger.log(`[PERFORMANCE] CDR total=${cdr.length} agentFiltered=${agentCdr.length} agent="${agent}"`);
|
||||||
|
if (cdr.length > 0 && agentCdr.length === 0) {
|
||||||
|
const sampleIds = cdr.slice(0, 3).map((c: any) => `AgentID="${c.AgentID}" AgentName="${c.AgentName}"`);
|
||||||
|
this.logger.warn(`[PERFORMANCE] No CDR match for agent "${agent}". Sample CDR agents: ${sampleIds.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalCalls = agentCdr.length;
|
||||||
|
const inbound = agentCdr.filter((c: any) => (c.Type ?? '').toLowerCase() === 'inbound').length;
|
||||||
|
const outbound = agentCdr.filter((c: any) => {
|
||||||
|
const type = (c.Type ?? '').toLowerCase();
|
||||||
|
return type === 'manual' || type === 'progressive' || type === 'outbound';
|
||||||
|
}).length;
|
||||||
|
const answered = agentCdr.filter((c: any) => (c.Status ?? '').toLowerCase() === 'answered').length;
|
||||||
|
const missed = agentCdr.filter((c: any) => (c.Status ?? '').toLowerCase() === 'notanswered').length;
|
||||||
|
|
||||||
|
const talkTimes = agentCdr
|
||||||
.filter((c: any) => c.TalkTime && c.TalkTime !== '00:00:00')
|
.filter((c: any) => c.TalkTime && c.TalkTime !== '00:00:00')
|
||||||
.map((c: any) => {
|
.map((c: any) => {
|
||||||
const parts = c.TalkTime.split(':').map(Number);
|
const parts = c.TalkTime.split(':').map(Number);
|
||||||
@@ -300,12 +497,12 @@ export class OzonetelAgentController {
|
|||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const dispositions: Record<string, number> = {};
|
const dispositions: Record<string, number> = {};
|
||||||
for (const c of cdr) {
|
for (const c of agentCdr) {
|
||||||
const d = (c as any).Disposition || 'No Disposition';
|
const d = (c as any).Disposition || 'No Disposition';
|
||||||
dispositions[d] = (dispositions[d] ?? 0) + 1;
|
dispositions[d] = (dispositions[d] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const appointmentsBooked = cdr.filter((c: any) =>
|
const appointmentsBooked = agentCdr.filter((c: any) =>
|
||||||
c.Disposition?.toLowerCase().includes('appointment'),
|
c.Disposition?.toLowerCase().includes('appointment'),
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
@@ -316,7 +513,7 @@ export class OzonetelAgentController {
|
|||||||
avgHandlingTime: aht,
|
avgHandlingTime: aht,
|
||||||
conversionRate: totalCalls > 0 ? Math.round((appointmentsBooked / totalCalls) * 100) : 0,
|
conversionRate: totalCalls > 0 ? Math.round((appointmentsBooked / totalCalls) * 100) : 0,
|
||||||
appointmentsBooked,
|
appointmentsBooked,
|
||||||
timeUtilization: summary,
|
timeUtilization,
|
||||||
dispositions,
|
dispositions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -325,12 +522,63 @@ export class OzonetelAgentController {
|
|||||||
// Campaign only has 'General Enquiry' configured currently
|
// Campaign only has 'General Enquiry' configured currently
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
'APPOINTMENT_BOOKED': 'General Enquiry',
|
'APPOINTMENT_BOOKED': 'General Enquiry',
|
||||||
|
'APPOINTMENT_RESCHEDULED': 'General Enquiry',
|
||||||
|
'APPOINTMENT_CANCELLED': 'General Enquiry',
|
||||||
'FOLLOW_UP_SCHEDULED': 'General Enquiry',
|
'FOLLOW_UP_SCHEDULED': 'General Enquiry',
|
||||||
'INFO_PROVIDED': 'General Enquiry',
|
'INFO_PROVIDED': 'General Enquiry',
|
||||||
'NO_ANSWER': 'General Enquiry',
|
'NO_ANSWER': 'General Enquiry',
|
||||||
'WRONG_NUMBER': 'General Enquiry',
|
'WRONG_NUMBER': 'General Enquiry',
|
||||||
|
'NOT_INTERESTED': 'General Enquiry',
|
||||||
'CALLBACK_REQUESTED': 'General Enquiry',
|
'CALLBACK_REQUESTED': 'General Enquiry',
|
||||||
};
|
};
|
||||||
return map[disposition] ?? 'General Enquiry';
|
return map[disposition] ?? 'General Enquiry';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert our AgentSession rollup (seconds per category) into the HH:MM:SS
|
||||||
|
// shape the frontend expects — so My Performance gets LOGIN TIME with the
|
||||||
|
// current open session included, not just closed sessions from Ozonetel.
|
||||||
|
private async fetchAgentSessionTimeBreakdown(ozonetelAgentId: string, date: string): Promise<{
|
||||||
|
totalLoginDuration: string;
|
||||||
|
totalBusyTime: string;
|
||||||
|
totalIdleTime: string;
|
||||||
|
totalPauseTime: string;
|
||||||
|
totalWrapupTime: string;
|
||||||
|
totalDialTime: string;
|
||||||
|
} | null> {
|
||||||
|
try {
|
||||||
|
const agentUuid = await this.agentLookup.resolveByOzonetelId(ozonetelAgentId);
|
||||||
|
if (!agentUuid) return null;
|
||||||
|
const data = await this.platform.query<any>(
|
||||||
|
`{ agentSessions(first: 1, filter: {
|
||||||
|
agentId: { eq: "${agentUuid}" },
|
||||||
|
date: { eq: "${date}" }
|
||||||
|
}) { edges { node {
|
||||||
|
loginDurationS busyTimeS idleTimeS pauseTimeS wrapupTimeS dialTimeS
|
||||||
|
} } } }`,
|
||||||
|
);
|
||||||
|
const node = data?.agentSessions?.edges?.[0]?.node;
|
||||||
|
if (!node) return null;
|
||||||
|
const hms = (sec: number | null | undefined): string => {
|
||||||
|
const s = Math.max(0, Math.round(sec ?? 0));
|
||||||
|
const h = Math.floor(s / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
const r = s % 60;
|
||||||
|
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${r.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
// If the entire rollup is zero, treat as "no data yet" — fall back
|
||||||
|
// to Ozonetel's summaryReport so the KPI isn't all zeroes.
|
||||||
|
const total = (node.loginDurationS ?? 0) + (node.busyTimeS ?? 0) + (node.idleTimeS ?? 0) + (node.pauseTimeS ?? 0) + (node.wrapupTimeS ?? 0);
|
||||||
|
if (total === 0) return null;
|
||||||
|
return {
|
||||||
|
totalLoginDuration: hms(node.loginDurationS),
|
||||||
|
totalBusyTime: hms(node.busyTimeS),
|
||||||
|
totalIdleTime: hms(node.idleTimeS),
|
||||||
|
totalPauseTime: hms(node.pauseTimeS),
|
||||||
|
totalWrapupTime: hms(node.wrapupTimeS),
|
||||||
|
totalDialTime: hms(node.dialTimeS),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,15 @@ import { Module, forwardRef } from '@nestjs/common';
|
|||||||
import { OzonetelAgentController } from './ozonetel-agent.controller';
|
import { OzonetelAgentController } from './ozonetel-agent.controller';
|
||||||
import { OzonetelAgentService } from './ozonetel-agent.service';
|
import { OzonetelAgentService } from './ozonetel-agent.service';
|
||||||
import { KookooIvrController } from './kookoo-ivr.controller';
|
import { KookooIvrController } from './kookoo-ivr.controller';
|
||||||
|
import { CdrEnrichmentService } from './cdr-enrichment.service';
|
||||||
import { WorklistModule } from '../worklist/worklist.module';
|
import { WorklistModule } from '../worklist/worklist.module';
|
||||||
import { PlatformModule } from '../platform/platform.module';
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
import { SupervisorModule } from '../supervisor/supervisor.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule, forwardRef(() => WorklistModule)],
|
imports: [PlatformModule, forwardRef(() => WorklistModule), forwardRef(() => SupervisorModule)],
|
||||||
controllers: [OzonetelAgentController, KookooIvrController],
|
controllers: [OzonetelAgentController, KookooIvrController],
|
||||||
providers: [OzonetelAgentService],
|
providers: [OzonetelAgentService, CdrEnrichmentService],
|
||||||
exports: [OzonetelAgentService],
|
exports: [OzonetelAgentService, CdrEnrichmentService],
|
||||||
})
|
})
|
||||||
export class OzonetelAgentModule {}
|
export class OzonetelAgentModule {}
|
||||||
|
|||||||
269
src/ozonetel/ozonetel-agent.service.spec.ts
Normal file
269
src/ozonetel/ozonetel-agent.service.spec.ts
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
/**
|
||||||
|
* Ozonetel Agent Service — unit tests
|
||||||
|
*
|
||||||
|
* QA coverage: agent auth (login/logout), manual dial, set disposition,
|
||||||
|
* change agent state, call control. Covers the Ozonetel HTTP layer that
|
||||||
|
* backs TC-IB-01→06, TC-OB-01→06, TC-FU-01→02 via disposition flows.
|
||||||
|
*
|
||||||
|
* axios is mocked — no real HTTP to Ozonetel.
|
||||||
|
*/
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { OzonetelAgentService } from './ozonetel-agent.service';
|
||||||
|
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||||
|
import axios from 'axios';
|
||||||
|
import {
|
||||||
|
AGENT_AUTH_LOGIN_SUCCESS,
|
||||||
|
AGENT_AUTH_LOGIN_ALREADY,
|
||||||
|
AGENT_AUTH_LOGOUT_SUCCESS,
|
||||||
|
AGENT_AUTH_INVALID,
|
||||||
|
DISPOSITION_SET_DURING_CALL,
|
||||||
|
DISPOSITION_SET_AFTER_CALL,
|
||||||
|
DISPOSITION_INVALID_UCID,
|
||||||
|
} from '../__fixtures__/ozonetel-payloads';
|
||||||
|
|
||||||
|
jest.mock('axios');
|
||||||
|
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||||
|
|
||||||
|
describe('OzonetelAgentService', () => {
|
||||||
|
let service: OzonetelAgentService;
|
||||||
|
|
||||||
|
const mockTelephonyConfig = {
|
||||||
|
exotel: {
|
||||||
|
apiKey: 'KK_TEST_KEY',
|
||||||
|
accountSid: 'test_account',
|
||||||
|
subdomain: 'in1-ccaas-api.ozonetel.com',
|
||||||
|
},
|
||||||
|
ozonetel: {
|
||||||
|
agentId: 'global',
|
||||||
|
agentPassword: 'Test123$',
|
||||||
|
sipId: '523590',
|
||||||
|
campaignName: 'Inbound_918041763400',
|
||||||
|
did: '918041763400',
|
||||||
|
},
|
||||||
|
sip: { domain: 'blr-pub-rtc4.ozonetel.com', wsPort: '444' },
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Mock token generation (needed before most API calls)
|
||||||
|
mockedAxios.post.mockImplementation(async (url: string, data?: any) => {
|
||||||
|
if (url.includes('generateToken')) {
|
||||||
|
return { data: { token: 'mock-bearer-token', status: 'success' } };
|
||||||
|
}
|
||||||
|
return { data: {} };
|
||||||
|
});
|
||||||
|
|
||||||
|
const module = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
OzonetelAgentService,
|
||||||
|
{
|
||||||
|
provide: TelephonyConfigService,
|
||||||
|
useValue: { getConfig: () => mockTelephonyConfig },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get(OzonetelAgentService);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Agent Login ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('loginAgent', () => {
|
||||||
|
it('should send correct params to Ozonetel auth endpoint', async () => {
|
||||||
|
mockedAxios.post.mockResolvedValueOnce({ data: AGENT_AUTH_LOGIN_SUCCESS });
|
||||||
|
|
||||||
|
const result = await service.loginAgent({
|
||||||
|
agentId: 'global',
|
||||||
|
password: 'Test123$',
|
||||||
|
phoneNumber: '523590',
|
||||||
|
mode: 'blended',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual(AGENT_AUTH_LOGIN_SUCCESS);
|
||||||
|
|
||||||
|
const authCall = mockedAxios.post.mock.calls[0];
|
||||||
|
expect(authCall[0]).toContain('AgentAuthenticationV2');
|
||||||
|
// Body is URLSearchParams string
|
||||||
|
expect(authCall[1]).toContain('userName=test_account');
|
||||||
|
expect(authCall[1]).toContain('apiKey=KK_TEST_KEY');
|
||||||
|
expect(authCall[1]).toContain('phoneNumber=523590');
|
||||||
|
expect(authCall[1]).toContain('action=login');
|
||||||
|
expect(authCall[1]).toContain('mode=blended');
|
||||||
|
// Basic auth
|
||||||
|
expect(authCall[2]?.auth).toEqual({ username: 'global', password: 'Test123$' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should auto-retry on "already logged in" response', async () => {
|
||||||
|
// First call: already logged in
|
||||||
|
mockedAxios.post
|
||||||
|
.mockResolvedValueOnce({ data: AGENT_AUTH_LOGIN_ALREADY })
|
||||||
|
// Logout call
|
||||||
|
.mockResolvedValueOnce({ data: AGENT_AUTH_LOGOUT_SUCCESS })
|
||||||
|
// Re-login call
|
||||||
|
.mockResolvedValueOnce({ data: AGENT_AUTH_LOGIN_SUCCESS });
|
||||||
|
|
||||||
|
const result = await service.loginAgent({
|
||||||
|
agentId: 'global',
|
||||||
|
password: 'Test123$',
|
||||||
|
phoneNumber: '523590',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should have made 3 calls: login, logout, re-login
|
||||||
|
expect(mockedAxios.post).toHaveBeenCalledTimes(3);
|
||||||
|
expect(result).toEqual(AGENT_AUTH_LOGIN_SUCCESS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on invalid authentication', async () => {
|
||||||
|
mockedAxios.post.mockRejectedValueOnce({
|
||||||
|
response: { status: 401, data: AGENT_AUTH_INVALID },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.loginAgent({ agentId: 'bad', password: 'wrong', phoneNumber: '000' }),
|
||||||
|
).rejects.toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Agent Logout ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('logoutAgent', () => {
|
||||||
|
it('should send logout action', async () => {
|
||||||
|
mockedAxios.post.mockResolvedValueOnce({ data: AGENT_AUTH_LOGOUT_SUCCESS });
|
||||||
|
|
||||||
|
const result = await service.logoutAgent({ agentId: 'global', password: 'Test123$' });
|
||||||
|
|
||||||
|
expect(result).toEqual(AGENT_AUTH_LOGOUT_SUCCESS);
|
||||||
|
const call = mockedAxios.post.mock.calls[0];
|
||||||
|
expect(call[1]).toContain('action=logout');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Manual Dial ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('manualDial', () => {
|
||||||
|
it('should send correct params with bearer token', async () => {
|
||||||
|
// First call: token generation, second: manual dial
|
||||||
|
mockedAxios.post
|
||||||
|
.mockResolvedValueOnce({ data: { token: 'mock-token', status: 'success' } })
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: { status: 'success', ucid: '31712345678901234', message: 'Call initiated' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.manualDial({
|
||||||
|
agentId: 'global',
|
||||||
|
campaignName: 'Inbound_918041763400',
|
||||||
|
customerNumber: '9949879837',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual(expect.objectContaining({ status: 'success' }));
|
||||||
|
|
||||||
|
// The dial call (second post)
|
||||||
|
const dialCall = mockedAxios.post.mock.calls[1];
|
||||||
|
expect(dialCall[0]).toContain('AgentManualDial');
|
||||||
|
expect(dialCall[1]).toMatchObject({
|
||||||
|
userName: 'test_account',
|
||||||
|
agentID: 'global',
|
||||||
|
campaignName: 'Inbound_918041763400',
|
||||||
|
customerNumber: '9949879837',
|
||||||
|
});
|
||||||
|
expect(dialCall[2]?.headers?.Authorization).toBe('Bearer mock-token');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Set Disposition ──────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('setDisposition', () => {
|
||||||
|
it('should send disposition with correct fields', async () => {
|
||||||
|
mockedAxios.post
|
||||||
|
.mockResolvedValueOnce({ data: { token: 'mock-token', status: 'success' } })
|
||||||
|
.mockResolvedValueOnce({ data: DISPOSITION_SET_AFTER_CALL });
|
||||||
|
|
||||||
|
const result = await service.setDisposition({
|
||||||
|
agentId: 'global',
|
||||||
|
ucid: '31712345678901234',
|
||||||
|
disposition: 'General Enquiry',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual(DISPOSITION_SET_AFTER_CALL);
|
||||||
|
|
||||||
|
const dispCall = mockedAxios.post.mock.calls[1];
|
||||||
|
expect(dispCall[0]).toContain('DispositionAPIV2');
|
||||||
|
expect(dispCall[1]).toMatchObject({
|
||||||
|
userName: 'test_account',
|
||||||
|
agentID: 'global',
|
||||||
|
ucid: '31712345678901234',
|
||||||
|
action: 'Set',
|
||||||
|
disposition: 'General Enquiry',
|
||||||
|
did: '918041763400',
|
||||||
|
autoRelease: 'true',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle queued disposition (during call)', async () => {
|
||||||
|
mockedAxios.post
|
||||||
|
.mockResolvedValueOnce({ data: { token: 'mock-token', status: 'success' } })
|
||||||
|
.mockResolvedValueOnce({ data: DISPOSITION_SET_DURING_CALL });
|
||||||
|
|
||||||
|
const result = await service.setDisposition({
|
||||||
|
agentId: 'global',
|
||||||
|
ucid: '31712345678901234',
|
||||||
|
disposition: 'Appointment Booked',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe('Success');
|
||||||
|
expect(result.message).toContain('Queued');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Change Agent State ───────────────────────────────────────
|
||||||
|
|
||||||
|
describe('changeAgentState', () => {
|
||||||
|
it('should send Ready state', async () => {
|
||||||
|
mockedAxios.post
|
||||||
|
.mockResolvedValueOnce({ data: { token: 'mock-token', status: 'success' } })
|
||||||
|
.mockResolvedValueOnce({ data: { status: 'success', message: 'State changed' } });
|
||||||
|
|
||||||
|
await service.changeAgentState({ agentId: 'global', state: 'Ready' });
|
||||||
|
|
||||||
|
const stateCall = mockedAxios.post.mock.calls[1];
|
||||||
|
expect(stateCall[0]).toContain('changeAgentState');
|
||||||
|
expect(stateCall[1]).toMatchObject({
|
||||||
|
userName: 'test_account',
|
||||||
|
agentId: 'global',
|
||||||
|
state: 'Ready',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include pauseReason when pausing', async () => {
|
||||||
|
mockedAxios.post
|
||||||
|
.mockResolvedValueOnce({ data: { token: 'mock-token', status: 'success' } })
|
||||||
|
.mockResolvedValueOnce({ data: { status: 'success', message: 'Agent paused' } });
|
||||||
|
|
||||||
|
await service.changeAgentState({ agentId: 'global', state: 'Pause', pauseReason: 'Break' });
|
||||||
|
|
||||||
|
const stateCall = mockedAxios.post.mock.calls[1];
|
||||||
|
expect(stateCall[1]).toMatchObject({ pauseReason: 'Break' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Token caching ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('token management', () => {
|
||||||
|
it('should cache token and reuse for subsequent calls', async () => {
|
||||||
|
// First call generates token
|
||||||
|
mockedAxios.post
|
||||||
|
.mockResolvedValueOnce({ data: { token: 'cached-token', status: 'success' } })
|
||||||
|
.mockResolvedValueOnce({ data: { status: 'success' } })
|
||||||
|
// Second API call should reuse token
|
||||||
|
.mockResolvedValueOnce({ data: { status: 'success' } });
|
||||||
|
|
||||||
|
await service.manualDial({ agentId: 'a', campaignName: 'c', customerNumber: '1' });
|
||||||
|
await service.manualDial({ agentId: 'a', campaignName: 'c', customerNumber: '2' });
|
||||||
|
|
||||||
|
// Token generation should only be called once
|
||||||
|
const tokenCalls = mockedAxios.post.mock.calls.filter(c => c[0].includes('generateToken'));
|
||||||
|
expect(tokenCalls.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,20 +1,27 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OzonetelAgentService {
|
export class OzonetelAgentService {
|
||||||
private readonly logger = new Logger(OzonetelAgentService.name);
|
private readonly logger = new Logger(OzonetelAgentService.name);
|
||||||
private readonly apiDomain: string;
|
|
||||||
private readonly apiKey: string;
|
|
||||||
private readonly accountId: string;
|
|
||||||
private cachedToken: string | null = null;
|
private cachedToken: string | null = null;
|
||||||
private tokenExpiry: number = 0;
|
private tokenExpiry: number = 0;
|
||||||
|
|
||||||
constructor(private config: ConfigService) {
|
constructor(private telephony: TelephonyConfigService) {}
|
||||||
this.apiDomain = config.get<string>('exotel.subdomain') ?? 'in1-ccaas-api.ozonetel.com';
|
|
||||||
this.apiKey = config.get<string>('exotel.apiKey') ?? '';
|
// Read-through getters so admin updates to telephony.json take effect
|
||||||
this.accountId = config.get<string>('exotel.accountSid') ?? '';
|
// immediately without a sidecar restart. The default for apiDomain is
|
||||||
|
// preserved here because the legacy env-var path used a different default
|
||||||
|
// ('in1-ccaas-api.ozonetel.com') than the rest of the Exotel config.
|
||||||
|
private get apiDomain(): string {
|
||||||
|
return this.telephony.getConfig().exotel.subdomain || 'in1-ccaas-api.ozonetel.com';
|
||||||
|
}
|
||||||
|
private get apiKey(): string {
|
||||||
|
return this.telephony.getConfig().exotel.apiKey;
|
||||||
|
}
|
||||||
|
private get accountId(): string {
|
||||||
|
return this.telephony.getConfig().exotel.accountSid;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getToken(): Promise<string> {
|
private async getToken(): Promise<string> {
|
||||||
@@ -196,7 +203,7 @@ export class OzonetelAgentService {
|
|||||||
disposition: string;
|
disposition: string;
|
||||||
}): Promise<{ status: string; message?: string; details?: string }> {
|
}): Promise<{ status: string; message?: string; details?: string }> {
|
||||||
const url = `https://${this.apiDomain}/ca_apis/DispositionAPIV2`;
|
const url = `https://${this.apiDomain}/ca_apis/DispositionAPIV2`;
|
||||||
const did = process.env.OZONETEL_DID ?? '918041763265';
|
const did = this.telephony.getConfig().ozonetel.did;
|
||||||
|
|
||||||
this.logger.log(`Set disposition: agent=${params.agentId} ucid=${params.ucid} disposition=${params.disposition}`);
|
this.logger.log(`Set disposition: agent=${params.agentId} ucid=${params.ucid} disposition=${params.disposition}`);
|
||||||
|
|
||||||
@@ -232,8 +239,9 @@ export class OzonetelAgentService {
|
|||||||
conferenceNumber?: string;
|
conferenceNumber?: string;
|
||||||
}): Promise<{ status: string; message: string; ucid?: string }> {
|
}): Promise<{ status: string; message: string; ucid?: string }> {
|
||||||
const url = `https://${this.apiDomain}/ca_apis/CallControl_V4`;
|
const url = `https://${this.apiDomain}/ca_apis/CallControl_V4`;
|
||||||
const did = process.env.OZONETEL_DID ?? '918041763265';
|
const tcfg = this.telephony.getConfig().ozonetel;
|
||||||
const agentPhoneName = process.env.OZONETEL_SIP_ID ?? '523590';
|
const did = tcfg.did;
|
||||||
|
const agentPhoneName = tcfg.sipId;
|
||||||
|
|
||||||
this.logger.log(`Call control: action=${params.action} ucid=${params.ucid} conference=${params.conferenceNumber ?? 'none'}`);
|
this.logger.log(`Call control: action=${params.action} ucid=${params.ucid} conference=${params.conferenceNumber ?? 'none'}`);
|
||||||
|
|
||||||
@@ -386,6 +394,48 @@ export class OzonetelAgentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch a single CDR record by UCID. Preferred over fetchCDR + .find()
|
||||||
|
// for recording lookups — Ozonetel resolves leg-pair UCIDs internally,
|
||||||
|
// so the agent-side UCID we hold reliably returns the call row.
|
||||||
|
// Same rate limit as fetchCDR (2 req/min, 15-day window).
|
||||||
|
async fetchCdrByUCID(params: { date: string; ucid: string }): Promise<Record<string, any> | null> {
|
||||||
|
const url = `https://${this.apiDomain}/ca_reports/fetchCdrByUCID`;
|
||||||
|
this.logger.log(`Fetch CDR by UCID: ucid=${params.ucid} date=${params.date}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = await this.getToken();
|
||||||
|
const body = {
|
||||||
|
userName: this.accountId,
|
||||||
|
fromDate: `${params.date} 00:00:00`,
|
||||||
|
toDate: `${params.date} 23:59:59`,
|
||||||
|
ucid: params.ucid,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await axios({
|
||||||
|
method: 'GET',
|
||||||
|
url,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
data: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = response.data;
|
||||||
|
if (data.status === 'success' && Array.isArray(data.details) && data.details.length > 0) {
|
||||||
|
return data.details[0];
|
||||||
|
}
|
||||||
|
if (data.status === 'success' && data.details && !Array.isArray(data.details)) {
|
||||||
|
return data.details;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error: any) {
|
||||||
|
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
|
||||||
|
this.logger.error(`Fetch CDR by UCID failed: ${error.message} ${responseData}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getAgentSummary(agentId: string, date: string): Promise<{
|
async getAgentSummary(agentId: string, date: string): Promise<{
|
||||||
totalLoginDuration: string;
|
totalLoginDuration: string;
|
||||||
totalBusyTime: string;
|
totalBusyTime: string;
|
||||||
|
|||||||
70
src/platform/agent-lookup.service.ts
Normal file
70
src/platform/agent-lookup.service.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { PlatformGraphqlService } from './platform-graphql.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps Ozonetel agent identifiers (unique — e.g. "ramaiahadmin",
|
||||||
|
* "globalhealthx", "global") to the platform Agent entity UUID. Used by
|
||||||
|
* ingest paths (webhook, dispose, CDR enrichment, backfill) so every Call
|
||||||
|
* ends up with the correct `agent` relation regardless of how Ozonetel
|
||||||
|
* formats the display name (AgentName collisions, transfer chains like
|
||||||
|
* "A -> B -> C", etc.).
|
||||||
|
*
|
||||||
|
* The cache is case-insensitive because Ozonetel occasionally mixes
|
||||||
|
* casing ("global" vs "Global" vs "GLOBAL") across webhook/CDR responses.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AgentLookupService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(AgentLookupService.name);
|
||||||
|
private readonly uuidByOzonetelId = new Map<string, string>();
|
||||||
|
private readonly uuidByDisplayName = new Map<string, string>();
|
||||||
|
|
||||||
|
constructor(private readonly platform: PlatformGraphqlService) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const data = await this.platform.query<any>(
|
||||||
|
`{ agents(first: 100) { edges { node { id ozonetelAgentId ozonetelDisplayName } } } }`,
|
||||||
|
);
|
||||||
|
const edges = data?.agents?.edges ?? [];
|
||||||
|
this.uuidByOzonetelId.clear();
|
||||||
|
this.uuidByDisplayName.clear();
|
||||||
|
for (const edge of edges) {
|
||||||
|
const n = edge.node;
|
||||||
|
if (n.ozonetelAgentId) {
|
||||||
|
this.uuidByOzonetelId.set(n.ozonetelAgentId.toLowerCase(), n.id);
|
||||||
|
}
|
||||||
|
if (n.ozonetelDisplayName) {
|
||||||
|
this.uuidByDisplayName.set(n.ozonetelDisplayName.toLowerCase().trim(), n.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.logger.log(`[AGENT-LOOKUP] Loaded ${this.uuidByOzonetelId.size} agents (${this.uuidByDisplayName.size} with display name)`);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[AGENT-LOOKUP] Refresh failed: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveByOzonetelId(ozonetelId: string | null | undefined): Promise<string | null> {
|
||||||
|
if (!ozonetelId) return null;
|
||||||
|
const key = ozonetelId.toLowerCase();
|
||||||
|
const cached = this.uuidByOzonetelId.get(key);
|
||||||
|
if (cached) return cached;
|
||||||
|
// Cache miss — refresh once (handles late-provisioned agents)
|
||||||
|
await this.refresh();
|
||||||
|
return this.uuidByOzonetelId.get(key) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve by Ozonetel display name (e.g. "Ganesh Bandi") — used by
|
||||||
|
// missed-call webhook backfill where only AgentName (display) is available.
|
||||||
|
async resolveByDisplayName(displayName: string | null | undefined): Promise<string | null> {
|
||||||
|
if (!displayName) return null;
|
||||||
|
const key = displayName.toLowerCase().trim();
|
||||||
|
const cached = this.uuidByDisplayName.get(key);
|
||||||
|
if (cached) return cached;
|
||||||
|
await this.refresh();
|
||||||
|
return this.uuidByDisplayName.get(key) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -180,10 +180,16 @@ export class PlatformGraphqlService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateLeadWithToken(id: string, input: UpdateLeadInput, authHeader: string): Promise<LeadNode> {
|
async updateLeadWithToken(id: string, input: UpdateLeadInput, authHeader: string): Promise<LeadNode> {
|
||||||
|
// Response fragment deliberately excludes `leadStatus` — the staging
|
||||||
|
// platform schema has this field renamed to `status`. Selecting the
|
||||||
|
// old name rejects the whole mutation. Callers don't use the
|
||||||
|
// returned fragment today, so returning just the id + AI fields
|
||||||
|
// keeps this working across both schema shapes without a wider
|
||||||
|
// rename hotfix.
|
||||||
const data = await this.queryWithAuth<{ updateLead: LeadNode }>(
|
const data = await this.queryWithAuth<{ updateLead: LeadNode }>(
|
||||||
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
|
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
|
||||||
updateLead(id: $id, data: $data) {
|
updateLead(id: $id, data: $data) {
|
||||||
id leadStatus aiSummary aiSuggestedAction
|
id aiSummary aiSuggestedAction
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
{ id, data: input },
|
{ id, data: input },
|
||||||
@@ -192,6 +198,67 @@ export class PlatformGraphqlService {
|
|||||||
return data.updateLead;
|
return data.updateLead;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch a single lead by id with the caller's JWT. Used by the
|
||||||
|
// lead-enrich flow when the agent explicitly renames a caller from
|
||||||
|
// the appointment/enquiry form and we need to regenerate the lead's
|
||||||
|
// AI summary against fresh identity.
|
||||||
|
//
|
||||||
|
// The selected fields deliberately use the staging-aligned names
|
||||||
|
// (`status`, `source`, `lastContacted`) rather than the older
|
||||||
|
// `leadStatus`/`leadSource`/`lastContactedAt` names — otherwise the
|
||||||
|
// query would be rejected on staging.
|
||||||
|
async findLeadByIdWithToken(id: string, authHeader: string): Promise<any | null> {
|
||||||
|
try {
|
||||||
|
const data = await this.queryWithAuth<{ lead: any }>(
|
||||||
|
`query FindLead($id: UUID!) {
|
||||||
|
lead(filter: { id: { eq: $id } }) {
|
||||||
|
id
|
||||||
|
createdAt
|
||||||
|
contactName { firstName lastName }
|
||||||
|
contactPhone { primaryPhoneNumber primaryPhoneCallingCode }
|
||||||
|
source
|
||||||
|
status
|
||||||
|
interestedService
|
||||||
|
contactAttempts
|
||||||
|
lastContacted
|
||||||
|
aiSummary
|
||||||
|
aiSuggestedAction
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ id },
|
||||||
|
authHeader,
|
||||||
|
);
|
||||||
|
return data.lead ?? null;
|
||||||
|
} catch {
|
||||||
|
// Fall back to edge-style query in case the singular field
|
||||||
|
// doesn't exist on this platform version.
|
||||||
|
const data = await this.queryWithAuth<{ leads: { edges: { node: any }[] } }>(
|
||||||
|
`query FindLead($id: UUID!) {
|
||||||
|
leads(filter: { id: { eq: $id } }, first: 1) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
createdAt
|
||||||
|
contactName { firstName lastName }
|
||||||
|
contactPhone { primaryPhoneNumber primaryPhoneCallingCode }
|
||||||
|
source
|
||||||
|
status
|
||||||
|
interestedService
|
||||||
|
contactAttempts
|
||||||
|
lastContacted
|
||||||
|
aiSummary
|
||||||
|
aiSuggestedAction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ id },
|
||||||
|
authHeader,
|
||||||
|
);
|
||||||
|
return data.leads.edges[0]?.node ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Server-to-server versions (for webhooks, background jobs) ---
|
// --- Server-to-server versions (for webhooks, background jobs) ---
|
||||||
|
|
||||||
async getLeadActivities(leadId: string, limit = 3): Promise<LeadActivityNode[]> {
|
async getLeadActivities(leadId: string, limit = 3): Promise<LeadActivityNode[]> {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PlatformGraphqlService } from './platform-graphql.service';
|
import { PlatformGraphqlService } from './platform-graphql.service';
|
||||||
|
import { AgentLookupService } from './agent-lookup.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [PlatformGraphqlService],
|
providers: [PlatformGraphqlService, AgentLookupService],
|
||||||
exports: [PlatformGraphqlService],
|
exports: [PlatformGraphqlService, AgentLookupService],
|
||||||
})
|
})
|
||||||
export class PlatformModule {}
|
export class PlatformModule {}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { generateObject } from 'ai';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { createAiModel } from '../ai/ai-provider';
|
import { createAiModel } from '../ai/ai-provider';
|
||||||
import type { LanguageModel } from 'ai';
|
import type { LanguageModel } from 'ai';
|
||||||
|
import { AiConfigService } from '../config/ai-config.service';
|
||||||
|
|
||||||
const DEEPGRAM_API = 'https://api.deepgram.com/v1/listen';
|
const DEEPGRAM_API = 'https://api.deepgram.com/v1/listen';
|
||||||
|
|
||||||
@@ -44,9 +45,18 @@ export class RecordingsService {
|
|||||||
private readonly deepgramApiKey: string;
|
private readonly deepgramApiKey: string;
|
||||||
private readonly aiModel: LanguageModel | null;
|
private readonly aiModel: LanguageModel | null;
|
||||||
|
|
||||||
constructor(private config: ConfigService) {
|
constructor(
|
||||||
|
private config: ConfigService,
|
||||||
|
private aiConfig: AiConfigService,
|
||||||
|
) {
|
||||||
this.deepgramApiKey = process.env.DEEPGRAM_API_KEY ?? '';
|
this.deepgramApiKey = process.env.DEEPGRAM_API_KEY ?? '';
|
||||||
this.aiModel = createAiModel(config);
|
const cfg = aiConfig.getConfig();
|
||||||
|
this.aiModel = createAiModel({
|
||||||
|
provider: cfg.provider,
|
||||||
|
model: cfg.model,
|
||||||
|
anthropicApiKey: config.get<string>('ai.anthropicApiKey'),
|
||||||
|
openaiApiKey: config.get<string>('ai.openaiApiKey'),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async analyzeRecording(recordingUrl: string): Promise<CallAnalysis> {
|
async analyzeRecording(recordingUrl: string): Promise<CallAnalysis> {
|
||||||
@@ -225,11 +235,11 @@ The CUSTOMER typically:
|
|||||||
patientSatisfaction: z.string().describe('One-line assessment of patient satisfaction'),
|
patientSatisfaction: z.string().describe('One-line assessment of patient satisfaction'),
|
||||||
callOutcome: z.string().describe('One-line summary of what was accomplished'),
|
callOutcome: z.string().describe('One-line summary of what was accomplished'),
|
||||||
}),
|
}),
|
||||||
system: `You are a call quality analyst for Global Hospital Bangalore.
|
system: this.aiConfig.renderPrompt('recordingAnalysis', {
|
||||||
Analyze the following call recording transcript and provide structured insights.
|
hospitalName: process.env.HOSPITAL_NAME ?? 'the hospital',
|
||||||
Be specific, brief, and actionable. Focus on healthcare context.
|
summaryBlock: summary ? `\nCall summary: ${summary}` : '',
|
||||||
${summary ? `\nCall summary: ${summary}` : ''}
|
topicsBlock: topics.length > 0 ? `\nDetected topics: ${topics.join(', ')}` : '',
|
||||||
${topics.length > 0 ? `\nDetected topics: ${topics.join(', ')}` : ''}`,
|
}),
|
||||||
prompt: transcript,
|
prompt: transcript,
|
||||||
maxOutputTokens: 500,
|
maxOutputTokens: 500,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,95 @@
|
|||||||
// src/rules-engine/actions/escalate.action.ts
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { PlatformGraphqlService } from '../../platform/platform-graphql.service';
|
||||||
import type { ActionHandler, ActionResult } from '../types/action.types';
|
import type { ActionHandler, ActionResult } from '../types/action.types';
|
||||||
import type { RuleAction } from '../types/rule.types';
|
import type { RuleAction, EscalateActionParams } from '../types/rule.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists a PerformanceAlert when a rule's escalate action fires.
|
||||||
|
*
|
||||||
|
* Dedupes by (agentId, alertType, IST date) — a single rule firing every
|
||||||
|
* 5 min should only produce ONE alert per day per agent until dismissed.
|
||||||
|
* If a row already exists for that key today and is not dismissed, the
|
||||||
|
* action is a no-op (returns the existing id). If the existing row was
|
||||||
|
* dismissed earlier today, we don't re-fire — supervisor explicitly
|
||||||
|
* acknowledged.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
export class EscalateActionHandler implements ActionHandler {
|
export class EscalateActionHandler implements ActionHandler {
|
||||||
type = 'escalate';
|
type = 'escalate';
|
||||||
|
private readonly logger = new Logger(EscalateActionHandler.name);
|
||||||
|
|
||||||
async execute(_action: RuleAction, _context: Record<string, any>): Promise<ActionResult> {
|
constructor(private readonly platform: PlatformGraphqlService) {}
|
||||||
return { success: true, data: { stub: true, action: 'escalate' } };
|
|
||||||
|
async execute(action: RuleAction, context: Record<string, any>): Promise<ActionResult> {
|
||||||
|
const params = action.params as EscalateActionParams & { ruleId?: string; alertType?: string };
|
||||||
|
const agentId = context['agent.id'] as string | undefined;
|
||||||
|
const agentName = (context['agent.name'] as string | undefined) ?? '';
|
||||||
|
const valueRaw = context['_alertValue'];
|
||||||
|
const valueText = valueRaw != null ? String(valueRaw) : null;
|
||||||
|
|
||||||
|
if (!agentId) {
|
||||||
|
return { success: false, error: 'agent.id missing from facts' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const alertType = params.alertType ?? this.inferAlertType(params.message);
|
||||||
|
const severity = (params.severity ?? 'warning').toUpperCase(); // INFO | WARNING | CRITICAL
|
||||||
|
const today = this.todayIst();
|
||||||
|
|
||||||
|
// Dedupe: any non-dismissed alert today for this agent + type?
|
||||||
|
try {
|
||||||
|
const existing = await this.platform.query<any>(
|
||||||
|
`{ performanceAlerts(first: 1, filter: {
|
||||||
|
agentId: { eq: "${agentId}" },
|
||||||
|
alertType: { eq: ${alertType} },
|
||||||
|
firedAt: { gte: "${today}T00:00:00+05:30", lte: "${today}T23:59:59+05:30" }
|
||||||
|
}) { edges { node { id dismissedAt value } } } }`,
|
||||||
|
);
|
||||||
|
const existingNode = existing?.performanceAlerts?.edges?.[0]?.node;
|
||||||
|
if (existingNode) {
|
||||||
|
// Already fired today. If value changed, update it; otherwise no-op.
|
||||||
|
if (!existingNode.dismissedAt && existingNode.value !== valueText) {
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation($id: UUID!, $data: PerformanceAlertUpdateInput!) { updatePerformanceAlert(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: existingNode.id, data: { value: valueText } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { success: true, data: { id: existingNode.id, deduped: true, agentId, alertType } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await this.platform.query<any>(
|
||||||
|
`mutation($data: PerformanceAlertCreateInput!) { createPerformanceAlert(data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
name: `${agentName || agentId}: ${params.message ?? alertType}${valueText ? ` (${valueText})` : ''}`,
|
||||||
|
agentId,
|
||||||
|
alertType,
|
||||||
|
severity,
|
||||||
|
message: params.message ?? alertType,
|
||||||
|
value: valueText,
|
||||||
|
ruleId: params.ruleId ?? null,
|
||||||
|
firedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const id = created?.createPerformanceAlert?.id;
|
||||||
|
this.logger.log(`[ESCALATE] Created alert ${id} agent=${agentName ?? agentId} type=${alertType} value=${valueText}`);
|
||||||
|
return { success: true, data: { id, agentId, alertType, severity, message: params.message } };
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`[ESCALATE] Failed for agent=${agentId}: ${err?.message ?? err}`);
|
||||||
|
return { success: false, error: String(err?.message ?? err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inferAlertType(message: string | undefined): string {
|
||||||
|
const m = (message ?? '').toLowerCase();
|
||||||
|
if (m.includes('idle')) return 'EXCESSIVE_IDLE';
|
||||||
|
if (m.includes('nps')) return 'LOW_NPS';
|
||||||
|
if (m.includes('conversion')) return 'LOW_CONVERSION';
|
||||||
|
return 'OTHER';
|
||||||
|
}
|
||||||
|
|
||||||
|
private todayIst(): string {
|
||||||
|
const ist = new Date(Date.now() + 5.5 * 60 * 60 * 1000);
|
||||||
|
return ist.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
114
src/rules-engine/consumers/performance.consumer.ts
Normal file
114
src/rules-engine/consumers/performance.consumer.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { RulesEngineService } from '../rules-engine.service';
|
||||||
|
import { RulesStorageService } from '../rules-storage.service';
|
||||||
|
import { PerformanceFactsProvider } from '../facts/performance-facts.provider';
|
||||||
|
import { PlatformGraphqlService } from '../../platform/platform-graphql.service';
|
||||||
|
|
||||||
|
const TICK_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
|
const KICKOFF_DELAY_MS = 90_000; // wait for boot to settle
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates `on_schedule` performance rules every 5 minutes for every
|
||||||
|
* platform Agent. Facts come from PerformanceFactsProvider; matching
|
||||||
|
* rules dispatch the escalate action which persists a PerformanceAlert.
|
||||||
|
*
|
||||||
|
* Skips quietly when no scheduled performance rules are configured.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PerformanceConsumer implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(PerformanceConsumer.name);
|
||||||
|
private timer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly engine: RulesEngineService,
|
||||||
|
private readonly storage: RulesStorageService,
|
||||||
|
private readonly facts: PerformanceFactsProvider,
|
||||||
|
private readonly platform: PlatformGraphqlService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.runOnce().catch((err) => {
|
||||||
|
this.logger.warn(`[PERF-CONSUMER] First run failed: ${err?.message ?? err}`);
|
||||||
|
});
|
||||||
|
}, KICKOFF_DELAY_MS);
|
||||||
|
|
||||||
|
this.timer = setInterval(() => {
|
||||||
|
this.runOnce().catch((err) => {
|
||||||
|
this.logger.warn(`[PERF-CONSUMER] Tick failed: ${err?.message ?? err}`);
|
||||||
|
});
|
||||||
|
}, TICK_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleDestroy() {
|
||||||
|
if (this.timer) clearInterval(this.timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async runOnce(): Promise<{ agentsScanned: number; alertsFired: number }> {
|
||||||
|
// Storage.getByTrigger doesn't sub-discriminate on_schedule rules, so
|
||||||
|
// filter to only those that reference agent.* facts in their conditions.
|
||||||
|
// Anything else (e.g. SLA-breach rules over call.* facts) belongs to
|
||||||
|
// other consumers.
|
||||||
|
const allScheduled = await this.storage.getByTrigger('on_schedule');
|
||||||
|
const rules = allScheduled.filter((r) => this.referencesAgentFacts(r.conditions));
|
||||||
|
if (rules.length === 0) {
|
||||||
|
this.logger.debug('[PERF-CONSUMER] No agent-fact on_schedule rules — skipping');
|
||||||
|
return { agentsScanned: 0, alertsFired: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const agents = await this.fetchAgents();
|
||||||
|
if (agents.length === 0) return { agentsScanned: 0, alertsFired: 0 };
|
||||||
|
|
||||||
|
let alertsFired = 0;
|
||||||
|
for (const agent of agents) {
|
||||||
|
try {
|
||||||
|
const factContext = await this.facts.resolveFacts({ agentId: agent.id, agentName: agent.name });
|
||||||
|
|
||||||
|
// Each rule's escalate action needs to know which fact value
|
||||||
|
// to surface as the alert's value (e.g. "65m" for idle).
|
||||||
|
// Inject _alertValue per-rule below.
|
||||||
|
for (const rule of rules) {
|
||||||
|
const ruleFacts = { ...factContext };
|
||||||
|
const valueFact = (rule.action.params as any)?.valueFact as string | undefined;
|
||||||
|
if (valueFact && ruleFacts[valueFact] != null) {
|
||||||
|
ruleFacts['_alertValue'] = ruleFacts[valueFact];
|
||||||
|
}
|
||||||
|
const result = await this.engine.evaluate('on_schedule', 'performance', ruleFacts);
|
||||||
|
alertsFired += result.results.filter((r: any) => r.success && !r.data?.deduped).length;
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`[PERF-CONSUMER] Eval failed for agent=${agent.id}: ${err?.message ?? err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alertsFired > 0) {
|
||||||
|
this.logger.log(`[PERF-CONSUMER] Tick complete — agents=${agents.length} alertsFired=${alertsFired}`);
|
||||||
|
}
|
||||||
|
return { agentsScanned: agents.length, alertsFired };
|
||||||
|
}
|
||||||
|
|
||||||
|
private referencesAgentFacts(group: any): boolean {
|
||||||
|
if (!group) return false;
|
||||||
|
const items = group.all ?? group.any ?? [];
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.all || item.any) {
|
||||||
|
if (this.referencesAgentFacts(item)) return true;
|
||||||
|
} else if (typeof item.fact === 'string' && item.fact.startsWith('agent.')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchAgents(): Promise<Array<{ id: string; name: string }>> {
|
||||||
|
try {
|
||||||
|
const data = await this.platform.query<any>(
|
||||||
|
`{ agents(first: 100) { edges { node { id name } } } }`,
|
||||||
|
);
|
||||||
|
return (data?.agents?.edges ?? []).map((e: any) => e.node);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[PERF-CONSUMER] Agent fetch failed: ${err}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,10 +18,10 @@ export class CallFactsProvider implements FactProvider {
|
|||||||
'call.status': call.callStatus ?? null,
|
'call.status': call.callStatus ?? null,
|
||||||
'call.disposition': call.disposition ?? null,
|
'call.disposition': call.disposition ?? null,
|
||||||
'call.durationSeconds': call.durationSeconds ?? call.durationSec ?? 0,
|
'call.durationSeconds': call.durationSeconds ?? call.durationSec ?? 0,
|
||||||
'call.callbackStatus': call.callbackstatus ?? call.callbackStatus ?? null,
|
'call.callbackStatus': call.callbackStatus ?? call.callbackStatus ?? null,
|
||||||
'call.slaElapsedPercent': slaElapsedPercent,
|
'call.slaElapsedPercent': slaElapsedPercent,
|
||||||
'call.slaBreached': slaElapsedPercent > 100,
|
'call.slaBreached': slaElapsedPercent > 100,
|
||||||
'call.missedCount': call.missedcallcount ?? call.missedCount ?? 0,
|
'call.missedCount': call.missedCallCount ?? call.missedCount ?? 0,
|
||||||
'call.taskType': taskType,
|
'call.taskType': taskType,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
93
src/rules-engine/facts/performance-facts.provider.ts
Normal file
93
src/rules-engine/facts/performance-facts.provider.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { PlatformGraphqlService } from '../../platform/platform-graphql.service';
|
||||||
|
import type { FactProvider, FactValue } from '../types/fact.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves per-agent performance facts for the rules engine.
|
||||||
|
* Used by the PerformanceConsumer to evaluate alert rules every 5 min.
|
||||||
|
*
|
||||||
|
* Facts exposed:
|
||||||
|
* - agent.idleMinutes — from today's AgentSession.idleTimeS
|
||||||
|
* - agent.busyMinutes — from AgentSession.busyTimeS
|
||||||
|
* - agent.totalCallsToday — count of Calls started today
|
||||||
|
* - agent.bookedCallsToday — count of Calls today with disposition=APPOINTMENT_BOOKED
|
||||||
|
* - agent.conversionPercent — bookedCallsToday / totalCallsToday × 100
|
||||||
|
* - agent.id, agent.name — for routing alerts back to the right agent
|
||||||
|
*
|
||||||
|
* NPS deferred — no source signal exists yet.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PerformanceFactsProvider implements FactProvider {
|
||||||
|
name = 'performance';
|
||||||
|
private readonly logger = new Logger(PerformanceFactsProvider.name);
|
||||||
|
|
||||||
|
constructor(private readonly platform: PlatformGraphqlService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param entityData { agentId: string, agentName?: string }
|
||||||
|
*/
|
||||||
|
async resolveFacts(entityData: { agentId: string; agentName?: string }): Promise<Record<string, FactValue>> {
|
||||||
|
const agentId = entityData.agentId;
|
||||||
|
const today = this.todayIst();
|
||||||
|
|
||||||
|
const session = await this.fetchTodaySession(agentId, today);
|
||||||
|
const callTotals = await this.fetchTodayCallTotals(agentId, today);
|
||||||
|
|
||||||
|
const idleMinutes = Math.round((session?.idleTimeS ?? 0) / 60);
|
||||||
|
const busyMinutes = Math.round((session?.busyTimeS ?? 0) / 60);
|
||||||
|
const conversionPercent = callTotals.total > 0
|
||||||
|
? Math.round((callTotals.booked / callTotals.total) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
'agent.id': agentId,
|
||||||
|
'agent.name': entityData.agentName ?? '',
|
||||||
|
'agent.idleMinutes': idleMinutes,
|
||||||
|
'agent.busyMinutes': busyMinutes,
|
||||||
|
'agent.totalCallsToday': callTotals.total,
|
||||||
|
'agent.bookedCallsToday': callTotals.booked,
|
||||||
|
'agent.conversionPercent': conversionPercent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private todayIst(): string {
|
||||||
|
const ist = new Date(Date.now() + 5.5 * 60 * 60 * 1000);
|
||||||
|
return ist.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchTodaySession(agentId: string, date: string): Promise<{ idleTimeS: number; busyTimeS: number } | null> {
|
||||||
|
try {
|
||||||
|
const data = await this.platform.query<any>(
|
||||||
|
`{ agentSessions(first: 1, filter: { agentId: { eq: "${agentId}" }, date: { eq: "${date}" } }) {
|
||||||
|
edges { node { idleTimeS busyTimeS } }
|
||||||
|
} }`,
|
||||||
|
);
|
||||||
|
const node = data?.agentSessions?.edges?.[0]?.node;
|
||||||
|
if (!node) return null;
|
||||||
|
return { idleTimeS: node.idleTimeS ?? 0, busyTimeS: node.busyTimeS ?? 0 };
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[PERF-FACTS] Session fetch failed for agent=${agentId}: ${err}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchTodayCallTotals(agentId: string, date: string): Promise<{ total: number; booked: number }> {
|
||||||
|
const gte = `${date}T00:00:00+05:30`;
|
||||||
|
const lte = `${date}T23:59:59+05:30`;
|
||||||
|
try {
|
||||||
|
const data = await this.platform.query<any>(
|
||||||
|
`{ calls(first: 200, filter: {
|
||||||
|
agentId: { eq: "${agentId}" },
|
||||||
|
startedAt: { gte: "${gte}", lte: "${lte}" }
|
||||||
|
}) { edges { node { disposition } } } }`,
|
||||||
|
);
|
||||||
|
const edges = data?.calls?.edges ?? [];
|
||||||
|
const total = edges.length;
|
||||||
|
const booked = edges.filter((e: any) => e.node.disposition === 'APPOINTMENT_BOOKED').length;
|
||||||
|
return { total, booked };
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[PERF-FACTS] Call totals fetch failed for agent=${agentId}: ${err}`);
|
||||||
|
return { total: 0, booked: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,26 @@
|
|||||||
// src/rules-engine/rules-engine.module.ts
|
// src/rules-engine/rules-engine.module.ts
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
import { RulesEngineController } from './rules-engine.controller';
|
import { RulesEngineController } from './rules-engine.controller';
|
||||||
import { RulesEngineService } from './rules-engine.service';
|
import { RulesEngineService } from './rules-engine.service';
|
||||||
import { RulesStorageService } from './rules-storage.service';
|
import { RulesStorageService } from './rules-storage.service';
|
||||||
import { WorklistConsumer } from './consumers/worklist.consumer';
|
import { WorklistConsumer } from './consumers/worklist.consumer';
|
||||||
|
import { PerformanceConsumer } from './consumers/performance.consumer';
|
||||||
|
import { EscalateActionHandler } from './actions/escalate.action';
|
||||||
|
import { PerformanceFactsProvider } from './facts/performance-facts.provider';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [PlatformModule],
|
||||||
controllers: [RulesEngineController],
|
controllers: [RulesEngineController],
|
||||||
providers: [RulesEngineService, RulesStorageService, WorklistConsumer],
|
providers: [
|
||||||
exports: [RulesEngineService, RulesStorageService, WorklistConsumer],
|
RulesEngineService,
|
||||||
|
RulesStorageService,
|
||||||
|
WorklistConsumer,
|
||||||
|
PerformanceConsumer,
|
||||||
|
EscalateActionHandler,
|
||||||
|
PerformanceFactsProvider,
|
||||||
|
],
|
||||||
|
exports: [RulesEngineService, RulesStorageService, WorklistConsumer, PerformanceConsumer],
|
||||||
})
|
})
|
||||||
export class RulesEngineModule {}
|
export class RulesEngineModule {}
|
||||||
|
|||||||
@@ -20,11 +20,14 @@ export class RulesEngineService {
|
|||||||
private readonly agentFacts = new AgentFactsProvider();
|
private readonly agentFacts = new AgentFactsProvider();
|
||||||
private readonly actionHandlers: Map<string, ActionHandler>;
|
private readonly actionHandlers: Map<string, ActionHandler>;
|
||||||
|
|
||||||
constructor(private readonly storage: RulesStorageService) {
|
constructor(
|
||||||
|
private readonly storage: RulesStorageService,
|
||||||
|
private readonly escalateHandler: EscalateActionHandler,
|
||||||
|
) {
|
||||||
this.actionHandlers = new Map([
|
this.actionHandlers = new Map([
|
||||||
['score', new ScoreActionHandler()],
|
['score', new ScoreActionHandler()],
|
||||||
['assign', new AssignActionHandler()],
|
['assign', new AssignActionHandler()],
|
||||||
['escalate', new EscalateActionHandler()],
|
['escalate', this.escalateHandler],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,51 @@
|
|||||||
"trigger": { "type": "on_schedule", "interval": "5m" },
|
"trigger": { "type": "on_schedule", "interval": "5m" },
|
||||||
"conditions": { "all": [{ "fact": "call.slaBreached", "operator": "equal", "value": true }, { "fact": "call.callbackStatus", "operator": "equal", "value": "PENDING_CALLBACK" }] },
|
"conditions": { "all": [{ "fact": "call.slaBreached", "operator": "equal", "value": true }, { "fact": "call.callbackStatus", "operator": "equal", "value": "PENDING_CALLBACK" }] },
|
||||||
"action": { "type": "escalate", "params": { "channel": "notification", "recipients": "supervisor", "message": "SLA breached — no callback attempted", "severity": "critical" } }
|
"action": { "type": "escalate", "params": { "channel": "notification", "recipients": "supervisor", "message": "SLA breached — no callback attempted", "severity": "critical" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ruleType": "automation",
|
||||||
|
"name": "Excessive idle time",
|
||||||
|
"description": "Agent has been idle for more than the configured threshold today",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 2,
|
||||||
|
"trigger": { "type": "on_schedule", "interval": "5m" },
|
||||||
|
"conditions": { "all": [{ "fact": "agent.idleMinutes", "operator": "greaterThan", "value": 60 }] },
|
||||||
|
"action": {
|
||||||
|
"type": "escalate",
|
||||||
|
"params": {
|
||||||
|
"channel": "notification",
|
||||||
|
"recipients": "supervisor",
|
||||||
|
"message": "Excessive Idle Time",
|
||||||
|
"severity": "warning",
|
||||||
|
"alertType": "EXCESSIVE_IDLE",
|
||||||
|
"valueFact": "agent.idleMinutes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ruleType": "automation",
|
||||||
|
"name": "Low conversion rate",
|
||||||
|
"description": "Agent's conversion (booked/total) is below the workspace floor",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 3,
|
||||||
|
"trigger": { "type": "on_schedule", "interval": "5m" },
|
||||||
|
"conditions": {
|
||||||
|
"all": [
|
||||||
|
{ "fact": "agent.conversionPercent", "operator": "lessThan", "value": 15 },
|
||||||
|
{ "fact": "agent.totalCallsToday", "operator": "greaterThan", "value": 10 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"type": "escalate",
|
||||||
|
"params": {
|
||||||
|
"channel": "notification",
|
||||||
|
"recipients": "supervisor",
|
||||||
|
"message": "Low Conversion",
|
||||||
|
"severity": "warning",
|
||||||
|
"alertType": "LOW_CONVERSION",
|
||||||
|
"valueFact": "agent.conversionPercent"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
151
src/shared/doctor-utils.ts
Normal file
151
src/shared/doctor-utils.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
// Shared utilities for working with the helix-engage Doctor entity
|
||||||
|
// after the multi-clinic visit-slot rework. The doctor data model
|
||||||
|
// changed from { clinic: RELATION, visitingHours: TEXT } to many
|
||||||
|
// DoctorVisitSlot records (one per day-of-week × clinic), so every
|
||||||
|
// service that fetches doctors needs to:
|
||||||
|
//
|
||||||
|
// 1. Query `visitSlots { dayOfWeek startTime endTime clinic { id clinicName } }`
|
||||||
|
// instead of the legacy flat fields.
|
||||||
|
// 2. Fold the slots back into a "where do they visit" summary string
|
||||||
|
// and a list of unique clinics for branch-matching.
|
||||||
|
//
|
||||||
|
// This file provides:
|
||||||
|
//
|
||||||
|
// - DOCTOR_VISIT_SLOTS_FRAGMENT: a string fragment that callers can
|
||||||
|
// splice into their `doctors { edges { node { ... } } }` query so
|
||||||
|
// the field selection stays consistent across services.
|
||||||
|
//
|
||||||
|
// - normalizeDoctor(d): takes the raw GraphQL node and returns the
|
||||||
|
// same object plus three derived fields:
|
||||||
|
// * `clinics: { id, clinicName }[]` — unique list of clinics
|
||||||
|
// the doctor visits, deduped by id.
|
||||||
|
// * `clinic: { clinicName } | null` — first clinic for legacy
|
||||||
|
// consumers that only show one (the AI prompt KB, etc.).
|
||||||
|
// * `visitingHours: string` — pre-formatted summary like
|
||||||
|
// "Mon 09:00-13:00 (Koramangala) · Wed 14:00-18:00 (Indiranagar)"
|
||||||
|
// suitable for inlining into AI prompts.
|
||||||
|
//
|
||||||
|
// Keeping the legacy field names (`clinic`, `visitingHours`) on the
|
||||||
|
// normalized object means call sites that previously read those
|
||||||
|
// fields keep working — only the GraphQL query and the call to
|
||||||
|
// normalizeDoctor need to be added.
|
||||||
|
|
||||||
|
export type RawDoctorVisitSlot = {
|
||||||
|
dayOfWeek?: string | null;
|
||||||
|
startTime?: string | null;
|
||||||
|
endTime?: string | null;
|
||||||
|
clinic?: { id?: string | null; clinicName?: string | null } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RawDoctor = {
|
||||||
|
id?: string;
|
||||||
|
name?: string | null;
|
||||||
|
fullName?: { firstName?: string | null; lastName?: string | null } | null;
|
||||||
|
department?: string | null;
|
||||||
|
specialty?: string | null;
|
||||||
|
visitSlots?: { edges?: Array<{ node: RawDoctorVisitSlot }> } | null;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tightened shape — `id` and `name` are always strings (with sensible
|
||||||
|
// fallbacks) so consumers can assign them to typed maps without
|
||||||
|
// "string | undefined" errors. The remaining fields keep their
|
||||||
|
// nullable nature from RawDoctor.
|
||||||
|
export type NormalizedDoctor = Omit<RawDoctor, 'id' | 'name'> & {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
clinics: Array<{ id: string; clinicName: string }>;
|
||||||
|
clinic: { clinicName: string } | null;
|
||||||
|
visitingHours: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// GraphQL fragment for the visit-slots reverse relation. Spliced into
|
||||||
|
// each doctors query so all services fetch the same shape. Capped at
|
||||||
|
// 20 slots per doctor — generous for any realistic schedule (7 days
|
||||||
|
// × 2-3 clinics).
|
||||||
|
export const DOCTOR_VISIT_SLOTS_FRAGMENT = `visitSlots(first: 20) {
|
||||||
|
edges { node {
|
||||||
|
dayOfWeek startTime endTime
|
||||||
|
clinic { id clinicName }
|
||||||
|
} }
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const DAY_ABBREV: Record<string, string> = {
|
||||||
|
MONDAY: 'Mon',
|
||||||
|
TUESDAY: 'Tue',
|
||||||
|
WEDNESDAY: 'Wed',
|
||||||
|
THURSDAY: 'Thu',
|
||||||
|
FRIDAY: 'Fri',
|
||||||
|
SATURDAY: 'Sat',
|
||||||
|
SUNDAY: 'Sun',
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (t: string | null | undefined): string => {
|
||||||
|
if (!t) return '';
|
||||||
|
// Times come in as "HH:MM" or "HH:MM:SS" — strip seconds for
|
||||||
|
// display compactness.
|
||||||
|
return t.length > 5 ? t.slice(0, 5) : t;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Best-effort doctor name derivation — prefer the platform's `name`
|
||||||
|
// field, then fall back to the composite fullName, then to a generic
|
||||||
|
// label so consumers never see undefined.
|
||||||
|
const deriveName = (raw: RawDoctor): string => {
|
||||||
|
if (raw.name && raw.name.trim()) return raw.name.trim();
|
||||||
|
const first = raw.fullName?.firstName?.trim() ?? '';
|
||||||
|
const last = raw.fullName?.lastName?.trim() ?? '';
|
||||||
|
const full = `${first} ${last}`.trim();
|
||||||
|
if (full) return full;
|
||||||
|
return 'Unknown doctor';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeDoctor = (raw: RawDoctor): NormalizedDoctor => {
|
||||||
|
const slots = raw.visitSlots?.edges?.map((e) => e.node) ?? [];
|
||||||
|
|
||||||
|
// Unique clinics, preserving the order they were encountered.
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const clinics: Array<{ id: string; clinicName: string }> = [];
|
||||||
|
for (const slot of slots) {
|
||||||
|
const id = slot.clinic?.id;
|
||||||
|
const name = slot.clinic?.clinicName;
|
||||||
|
if (!id || !name || seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
clinics.push({ id, clinicName: name });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visiting hours summary — `Day HH:MM-HH:MM (Clinic)` joined by
|
||||||
|
// " · ". Slots without a clinic or without a day get dropped.
|
||||||
|
const segments: string[] = [];
|
||||||
|
for (const slot of slots) {
|
||||||
|
const day = slot.dayOfWeek ? (DAY_ABBREV[slot.dayOfWeek] ?? slot.dayOfWeek) : null;
|
||||||
|
const start = formatTime(slot.startTime);
|
||||||
|
const end = formatTime(slot.endTime);
|
||||||
|
const clinic = slot.clinic?.clinicName;
|
||||||
|
if (!day || !start || !clinic) continue;
|
||||||
|
segments.push(`${day} ${start}${end ? `-${end}` : ''} (${clinic})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...raw,
|
||||||
|
id: raw.id ?? '',
|
||||||
|
name: deriveName(raw),
|
||||||
|
clinics,
|
||||||
|
// Bridge field — first clinic, so legacy consumers that read
|
||||||
|
// `d.clinic.clinicName` keep working.
|
||||||
|
clinic: clinics.length > 0 ? { clinicName: clinics[0].clinicName } : null,
|
||||||
|
visitingHours: segments.join(' · '),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convenience: normalize an array of raw GraphQL nodes in one call.
|
||||||
|
export const normalizeDoctors = (raws: RawDoctor[]): NormalizedDoctor[] => raws.map(normalizeDoctor);
|
||||||
|
|
||||||
|
// Branch-matching helper: a doctor "matches" a branch if any of their
|
||||||
|
// visit slots is at a clinic whose name contains the branch substring
|
||||||
|
// (case-insensitive). Used by widget chat tools to filter doctors by
|
||||||
|
// the visitor's selected branch.
|
||||||
|
export const doctorMatchesBranch = (d: NormalizedDoctor, branch: string | undefined | null): boolean => {
|
||||||
|
if (!branch) return true;
|
||||||
|
const needle = branch.toLowerCase();
|
||||||
|
return d.clinics.some((c) => c.clinicName.toLowerCase().includes(needle));
|
||||||
|
};
|
||||||
381
src/supervisor/agent-history.service.ts
Normal file
381
src/supervisor/agent-history.service.ts
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
|
||||||
|
// AgentEvent enum values (mirror of the SDK app's agent-event.object.ts).
|
||||||
|
// Ozonetel webhook actions → Helix event types.
|
||||||
|
export type AgentEventType =
|
||||||
|
| 'LOGIN'
|
||||||
|
| 'LOGOUT'
|
||||||
|
| 'READY'
|
||||||
|
| 'PAUSE'
|
||||||
|
| 'RESUME'
|
||||||
|
| 'CALL_START'
|
||||||
|
| 'CALL_END'
|
||||||
|
| 'ACW_START'
|
||||||
|
| 'ACW_END';
|
||||||
|
|
||||||
|
// Separate pending slots per event category. Call + ACW overlap (agent
|
||||||
|
// enters ACW before the CALL_END arrives), so a single shared slot would
|
||||||
|
// let ACW_START clobber pending CALL_START and produce 0-second call
|
||||||
|
// durations. Keep one slot per category so each END event pairs cleanly.
|
||||||
|
type PendingSlot = 'pause' | 'call' | 'acw';
|
||||||
|
type PendingStarts = {
|
||||||
|
pause?: number; // PAUSE eventAt ms
|
||||||
|
call?: number; // CALL_START eventAt ms
|
||||||
|
acw?: number; // ACW_START eventAt ms
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists agent activity and per-call timing into the platform entities
|
||||||
|
* we added in Phase 1 (AgentEvent, Call SLA fields). Reads AgentSession
|
||||||
|
* later via the rollup job.
|
||||||
|
*
|
||||||
|
* Called from:
|
||||||
|
* - supervisor.service.handleAgentEvent → persistAgentEvent()
|
||||||
|
* - supervisor.service.handleCallEvent → patchCallTiming()
|
||||||
|
* - ozonetel-agent.controller dispose flow → patchCallTiming()
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AgentHistoryService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(AgentHistoryService.name);
|
||||||
|
|
||||||
|
// ozonetelAgentId → Agent entity UUID. Loaded at startup.
|
||||||
|
private readonly agentUuidByOzonetelId = new Map<string, string>();
|
||||||
|
|
||||||
|
// agentId → map of pending start events per category, used to compute
|
||||||
|
// durationSec on the matching END event.
|
||||||
|
private readonly pendingStartsByAgent = new Map<string, PendingStarts>();
|
||||||
|
|
||||||
|
constructor(private readonly platform: PlatformGraphqlService) {}
|
||||||
|
|
||||||
|
private rollupTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.refreshAgentCache();
|
||||||
|
// Roll up today's sessions every 15 minutes. Rollup is idempotent
|
||||||
|
// (upsert by agent+date), so missing a tick is safe — the next tick
|
||||||
|
// recomputes from AgentEvent history. Written with setInterval because
|
||||||
|
// @nestjs/schedule isn't installed in this sidecar.
|
||||||
|
this.rollupTimer = setInterval(() => {
|
||||||
|
this.rollupSessions(this.currentSessionDate()).catch((err) => {
|
||||||
|
this.logger.warn(`[HISTORY] Rollup tick failed: ${err?.message ?? err}`);
|
||||||
|
});
|
||||||
|
}, 15 * 60 * 1000);
|
||||||
|
// Kick off one immediately so the dashboard has data on boot.
|
||||||
|
this.rollupSessions(this.currentSessionDate()).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleDestroy() {
|
||||||
|
if (this.rollupTimer) clearInterval(this.rollupTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// IST day boundary — agents work in IST, so the rollup is by IST date.
|
||||||
|
private currentSessionDate(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const ist = new Date(now.getTime() + 5.5 * 60 * 60 * 1000);
|
||||||
|
return ist.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshAgentCache(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const data = await this.platform.query<any>(
|
||||||
|
`{ agents(first: 50) { edges { node { id ozonetelAgentId } } } }`,
|
||||||
|
);
|
||||||
|
const edges = data?.agents?.edges ?? [];
|
||||||
|
this.agentUuidByOzonetelId.clear();
|
||||||
|
for (const edge of edges) {
|
||||||
|
const n = edge.node;
|
||||||
|
if (n.ozonetelAgentId) {
|
||||||
|
this.agentUuidByOzonetelId.set(n.ozonetelAgentId, n.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.logger.log(`[HISTORY] Loaded ${this.agentUuidByOzonetelId.size} agent UUIDs into cache`);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[HISTORY] Failed to refresh agent cache: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveAgentUuid(ozonetelAgentId: string): Promise<string | null> {
|
||||||
|
if (!ozonetelAgentId) return null;
|
||||||
|
const cached = this.agentUuidByOzonetelId.get(ozonetelAgentId);
|
||||||
|
if (cached) return cached;
|
||||||
|
// Cache miss — refresh once (handles late-provisioned agents like Ganesh)
|
||||||
|
await this.refreshAgentCache();
|
||||||
|
return this.agentUuidByOzonetelId.get(ozonetelAgentId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record an agent activity event. Computes durationSec for END events
|
||||||
|
* (RESUME, CALL_END, ACW_END) by pairing against the most recent START.
|
||||||
|
* Non-fatal on failure — realtime SSE flow continues even if the
|
||||||
|
* platform write errors.
|
||||||
|
*/
|
||||||
|
async persistAgentEvent(params: {
|
||||||
|
ozonetelAgentId: string;
|
||||||
|
eventType: AgentEventType;
|
||||||
|
eventAt: string; // ISO
|
||||||
|
pauseReason?: string | null;
|
||||||
|
callId?: string | null;
|
||||||
|
}): Promise<void> {
|
||||||
|
const agentUuid = await this.resolveAgentUuid(params.ozonetelAgentId);
|
||||||
|
if (!agentUuid) {
|
||||||
|
this.logger.warn(`[HISTORY] No Agent entity for ozonetelAgentId=${params.ozonetelAgentId} — skipping event persist`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pair START → END events by category. CALL and ACW can overlap
|
||||||
|
// (agent enters ACW before CALL_END arrives), so each lives in its
|
||||||
|
// own slot. READY is a fallback close — supervisor.service already
|
||||||
|
// maps 'release'/'IDLE' to RESUME / ACW_END when it knows the prior
|
||||||
|
// state; READY only fires when that disambiguation failed, so it
|
||||||
|
// clears anything dangling.
|
||||||
|
let durationSec: number | null = null;
|
||||||
|
const endSlot = this.slotForEnd(params.eventType);
|
||||||
|
const startSlot = this.slotForStart(params.eventType);
|
||||||
|
const eventMs = new Date(params.eventAt).getTime();
|
||||||
|
|
||||||
|
if (endSlot) {
|
||||||
|
const pending = this.pendingStartsByAgent.get(params.ozonetelAgentId);
|
||||||
|
const at = pending?.[endSlot];
|
||||||
|
if (at !== undefined) {
|
||||||
|
durationSec = Math.max(0, Math.round((eventMs - at) / 1000));
|
||||||
|
delete pending![endSlot];
|
||||||
|
if (!pending!.pause && !pending!.call && !pending!.acw) {
|
||||||
|
this.pendingStartsByAgent.delete(params.ozonetelAgentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (startSlot) {
|
||||||
|
const existing = this.pendingStartsByAgent.get(params.ozonetelAgentId) ?? {};
|
||||||
|
existing[startSlot] = eventMs;
|
||||||
|
this.pendingStartsByAgent.set(params.ozonetelAgentId, existing);
|
||||||
|
} else if (params.eventType === 'READY' || params.eventType === 'LOGOUT') {
|
||||||
|
// Defensive flush of any lingering slots on session boundaries.
|
||||||
|
this.pendingStartsByAgent.delete(params.ozonetelAgentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: Record<string, any> = {
|
||||||
|
name: `${params.ozonetelAgentId} ${params.eventType}`,
|
||||||
|
eventType: params.eventType,
|
||||||
|
eventAt: params.eventAt,
|
||||||
|
source: 'OZONETEL_SUBSCRIPTION',
|
||||||
|
agentId: agentUuid,
|
||||||
|
};
|
||||||
|
if (params.pauseReason) data.pauseReason = params.pauseReason;
|
||||||
|
if (durationSec !== null) data.durationS = durationSec;
|
||||||
|
if (params.callId) data.callId = params.callId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation($data: AgentEventCreateInput!) { createAgentEvent(data: $data) { id } }`,
|
||||||
|
{ data },
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (this.isEntityMissingError(err)) {
|
||||||
|
if (!this.warnedEntityMissing) {
|
||||||
|
this.logger.warn('[HISTORY] AgentEvent entity not synced on this workspace — skipping persistence');
|
||||||
|
this.warnedEntityMissing = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.logger.warn(`[HISTORY] createAgentEvent failed: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private warnedEntityMissing = false;
|
||||||
|
|
||||||
|
private isEntityMissingError(err: unknown): boolean {
|
||||||
|
const msg = String((err as any)?.message ?? err ?? '');
|
||||||
|
return msg.includes('Cannot query field') || msg.includes('Unknown type')
|
||||||
|
|| msg.includes('AgentEventCreateInput') || msg.includes('AgentSessionCreateInput');
|
||||||
|
}
|
||||||
|
|
||||||
|
private slotForStart(eventType: AgentEventType): PendingSlot | null {
|
||||||
|
if (eventType === 'PAUSE') return 'pause';
|
||||||
|
if (eventType === 'CALL_START') return 'call';
|
||||||
|
if (eventType === 'ACW_START') return 'acw';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private slotForEnd(eventType: AgentEventType): PendingSlot | null {
|
||||||
|
if (eventType === 'RESUME') return 'pause';
|
||||||
|
if (eventType === 'CALL_END') return 'call';
|
||||||
|
if (eventType === 'ACW_END') return 'acw';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patch a Call record with SLA / timing fields derived from Ozonetel
|
||||||
|
* webhooks or post-call CDR. All fields optional — caller passes only
|
||||||
|
* what it has. Used for response-time and ACW histograms on the
|
||||||
|
* supervisor dashboard.
|
||||||
|
*/
|
||||||
|
async patchCallTiming(callId: string, fields: {
|
||||||
|
assignedAt?: string;
|
||||||
|
answeredAt?: string;
|
||||||
|
responseTimeSec?: number;
|
||||||
|
handlingTimeSec?: number;
|
||||||
|
acwDurationSec?: number;
|
||||||
|
holdDurationSec?: number;
|
||||||
|
}): Promise<void> {
|
||||||
|
// Platform truncates `*Sec` → `*S` on field names.
|
||||||
|
const fieldNameMap: Record<string, string> = {
|
||||||
|
responseTimeSec: 'responseTimeS',
|
||||||
|
handlingTimeSec: 'handlingTimeS',
|
||||||
|
acwDurationSec: 'acwDurationS',
|
||||||
|
holdDurationSec: 'holdDurationS',
|
||||||
|
};
|
||||||
|
const data: Record<string, any> = {};
|
||||||
|
for (const [k, v] of Object.entries(fields)) {
|
||||||
|
if (v !== undefined && v !== null) {
|
||||||
|
data[fieldNameMap[k] ?? k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(data).length === 0) return;
|
||||||
|
try {
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: callId, data },
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[HISTORY] updateCall timing failed (${callId}): ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate AgentEvent rows into an AgentSession row per agent for the
|
||||||
|
* given IST date. Called on a 15-minute interval; upserts by (agent,
|
||||||
|
* sessionDate) so re-runs are safe.
|
||||||
|
*/
|
||||||
|
async rollupSessions(sessionDate: string): Promise<void> {
|
||||||
|
if (this.agentUuidByOzonetelId.size === 0) await this.refreshAgentCache();
|
||||||
|
const agentUuids = Array.from(new Set(this.agentUuidByOzonetelId.values()));
|
||||||
|
if (agentUuids.length === 0) return;
|
||||||
|
|
||||||
|
const startIso = `${sessionDate}T00:00:00+05:30`;
|
||||||
|
const endIso = `${sessionDate}T23:59:59+05:30`;
|
||||||
|
|
||||||
|
let succeeded = 0;
|
||||||
|
for (const agentUuid of agentUuids) {
|
||||||
|
try {
|
||||||
|
const events = await this.fetchAgentEvents(agentUuid, startIso, endIso);
|
||||||
|
const totals = this.aggregateEvents(events);
|
||||||
|
await this.upsertSession(agentUuid, sessionDate, totals);
|
||||||
|
succeeded++;
|
||||||
|
} catch (err: any) {
|
||||||
|
if (this.isEntityMissingError(err)) {
|
||||||
|
if (!this.warnedEntityMissing) {
|
||||||
|
this.logger.warn('[HISTORY] AgentEvent/AgentSession entities not synced on this workspace — skipping rollup');
|
||||||
|
this.warnedEntityMissing = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.logger.warn(`[HISTORY] Rollup failed for agent ${agentUuid}: ${err?.message ?? err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.logger.log(`[HISTORY] Rollup complete for ${sessionDate} — ${succeeded}/${agentUuids.length} agents`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Platform strips the `Sec` suffix on numeric field names — schema uses
|
||||||
|
// `durationS`, `loginDurationS`, etc. Map back to our canonical names
|
||||||
|
// when reading.
|
||||||
|
private async fetchAgentEvents(agentUuid: string, startIso: string, endIso: string): Promise<Array<{ eventType: AgentEventType; durationSec: number | null; eventAt: string }>> {
|
||||||
|
const events: Array<{ eventType: AgentEventType; durationSec: number | null; eventAt: string }> = [];
|
||||||
|
let after: string | null = null;
|
||||||
|
for (let page = 0; page < 20; page++) {
|
||||||
|
const cursorArg: string = after ? `, after: "${after}"` : '';
|
||||||
|
const data: any = await this.platform.query<any>(
|
||||||
|
`{ agentEvents(first: 200${cursorArg}, filter: { agentId: { eq: "${agentUuid}" }, eventAt: { gte: "${startIso}", lte: "${endIso}" } }, orderBy: [{ eventAt: AscNullsLast }]) {
|
||||||
|
edges { node { eventType eventAt durationS } }
|
||||||
|
pageInfo { hasNextPage endCursor }
|
||||||
|
} }`,
|
||||||
|
);
|
||||||
|
const edges = data?.agentEvents?.edges ?? [];
|
||||||
|
for (const e of edges) {
|
||||||
|
events.push({
|
||||||
|
eventType: e.node.eventType,
|
||||||
|
eventAt: e.node.eventAt,
|
||||||
|
durationSec: e.node.durationS ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const pageInfo: { hasNextPage?: boolean; endCursor?: string } = data?.agentEvents?.pageInfo ?? {};
|
||||||
|
if (!pageInfo.hasNextPage) break;
|
||||||
|
after = pageInfo.endCursor ?? null;
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
private aggregateEvents(events: Array<{ eventType: AgentEventType; durationSec: number | null; eventAt: string }>) {
|
||||||
|
let busyTimeSec = 0;
|
||||||
|
let pauseTimeSec = 0;
|
||||||
|
let wrapupTimeSec = 0;
|
||||||
|
let handlingSum = 0;
|
||||||
|
let handlingCount = 0;
|
||||||
|
|
||||||
|
// Login duration: sum each LOGIN → (next LOGOUT on same day | now) span.
|
||||||
|
// Ozonetel doesn't emit a LOGOUT if the agent just closes the tab, so
|
||||||
|
// cap open sessions at the end of the rollup day.
|
||||||
|
let loginDurationSec = 0;
|
||||||
|
let openLoginAt: number | null = null;
|
||||||
|
|
||||||
|
for (const e of events) {
|
||||||
|
if (e.eventType === 'LOGIN') {
|
||||||
|
openLoginAt = new Date(e.eventAt).getTime();
|
||||||
|
} else if (e.eventType === 'LOGOUT' && openLoginAt !== null) {
|
||||||
|
loginDurationSec += Math.max(0, Math.round((new Date(e.eventAt).getTime() - openLoginAt) / 1000));
|
||||||
|
openLoginAt = null;
|
||||||
|
} else if (e.eventType === 'CALL_END' && e.durationSec) {
|
||||||
|
busyTimeSec += e.durationSec;
|
||||||
|
handlingSum += e.durationSec;
|
||||||
|
handlingCount++;
|
||||||
|
} else if (e.eventType === 'RESUME' && e.durationSec) {
|
||||||
|
pauseTimeSec += e.durationSec;
|
||||||
|
} else if (e.eventType === 'ACW_END' && e.durationSec) {
|
||||||
|
wrapupTimeSec += e.durationSec;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (openLoginAt !== null) {
|
||||||
|
// Still logged in — count up to now (capped to the rollup day end).
|
||||||
|
loginDurationSec += Math.max(0, Math.round((Date.now() - openLoginAt) / 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
const avgHandlingTimeSec = handlingCount > 0 ? Math.round(handlingSum / handlingCount) : null;
|
||||||
|
const idleTimeSec = Math.max(0, loginDurationSec - busyTimeSec - pauseTimeSec - wrapupTimeSec);
|
||||||
|
|
||||||
|
return { loginDurationSec, busyTimeSec, pauseTimeSec, wrapupTimeSec, idleTimeSec, avgHandlingTimeSec };
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentSession fields map: our `*Sec` → platform `*S`, `sessionDate` → `date`.
|
||||||
|
private async upsertSession(
|
||||||
|
agentUuid: string,
|
||||||
|
sessionDate: string,
|
||||||
|
totals: { loginDurationSec: number; busyTimeSec: number; pauseTimeSec: number; wrapupTimeSec: number; idleTimeSec: number; avgHandlingTimeSec: number | null },
|
||||||
|
): Promise<void> {
|
||||||
|
const existing = await this.platform.query<any>(
|
||||||
|
`{ agentSessions(first: 1, filter: { agentId: { eq: "${agentUuid}" }, date: { eq: "${sessionDate}" } }) { edges { node { id } } } }`,
|
||||||
|
);
|
||||||
|
const existingId = existing?.agentSessions?.edges?.[0]?.node?.id;
|
||||||
|
|
||||||
|
const data: Record<string, any> = {
|
||||||
|
loginDurationS: totals.loginDurationSec,
|
||||||
|
busyTimeS: totals.busyTimeSec,
|
||||||
|
pauseTimeS: totals.pauseTimeSec,
|
||||||
|
wrapupTimeS: totals.wrapupTimeSec,
|
||||||
|
idleTimeS: totals.idleTimeSec,
|
||||||
|
source: 'COMPUTED',
|
||||||
|
lastSyncedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
if (totals.avgHandlingTimeSec !== null) data.avgHandlingTimeS = totals.avgHandlingTimeSec;
|
||||||
|
|
||||||
|
if (existingId) {
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation($id: UUID!, $data: AgentSessionUpdateInput!) { updateAgentSession(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: existingId, data },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation($data: AgentSessionCreateInput!) { createAgentSession(data: $data) { id } }`,
|
||||||
|
{ data: { ...data, name: `Session ${sessionDate}`, agentId: agentUuid, date: sessionDate } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/supervisor/performance-alerts.controller.ts
Normal file
91
src/supervisor/performance-alerts.controller.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { Controller, Get, Post, Param, Logger } from '@nestjs/common';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read API for the supervisor notification bell. Returns active (non-
|
||||||
|
* dismissed) PerformanceAlert rows the rules engine has emitted.
|
||||||
|
*
|
||||||
|
* Frontend polls every 60s. Dismiss is per-alert.
|
||||||
|
*/
|
||||||
|
@Controller('api/supervisor/performance-alerts')
|
||||||
|
export class PerformanceAlertsController {
|
||||||
|
private readonly logger = new Logger(PerformanceAlertsController.name);
|
||||||
|
|
||||||
|
constructor(private readonly platform: PlatformGraphqlService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async list() {
|
||||||
|
const data = await this.platform.query<any>(
|
||||||
|
`{ performanceAlerts(
|
||||||
|
first: 50,
|
||||||
|
filter: { dismissedAt: { is: NULL } },
|
||||||
|
orderBy: [{ firedAt: DescNullsLast }]
|
||||||
|
) {
|
||||||
|
edges { node {
|
||||||
|
id alertType severity message value ruleId firedAt
|
||||||
|
agent { id name }
|
||||||
|
} }
|
||||||
|
} }`,
|
||||||
|
);
|
||||||
|
const edges = data?.performanceAlerts?.edges ?? [];
|
||||||
|
return {
|
||||||
|
alerts: edges.map((e: any) => {
|
||||||
|
const n = e.node;
|
||||||
|
return {
|
||||||
|
id: n.id,
|
||||||
|
agent: n.agent?.name ?? 'Unknown',
|
||||||
|
agentId: n.agent?.id ?? null,
|
||||||
|
type: this.toLabel(n.alertType),
|
||||||
|
severity: (n.severity ?? 'WARNING').toLowerCase(),
|
||||||
|
value: n.value ?? '',
|
||||||
|
message: n.message,
|
||||||
|
firedAt: n.firedAt,
|
||||||
|
ruleId: n.ruleId,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/dismiss')
|
||||||
|
async dismiss(@Param('id') id: string) {
|
||||||
|
try {
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation($id: UUID!, $data: PerformanceAlertUpdateInput!) { updatePerformanceAlert(id: $id, data: $data) { id } }`,
|
||||||
|
{ id, data: { dismissedAt: new Date().toISOString() } },
|
||||||
|
);
|
||||||
|
return { status: 'ok' };
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`[ALERTS] Dismiss failed for ${id}: ${err?.message ?? err}`);
|
||||||
|
return { status: 'error', message: String(err?.message ?? err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toLabel(alertType: string | null | undefined): string {
|
||||||
|
switch (alertType) {
|
||||||
|
case 'EXCESSIVE_IDLE': return 'Excessive Idle Time';
|
||||||
|
case 'LOW_NPS': return 'Low NPS';
|
||||||
|
case 'LOW_CONVERSION': return 'Low Conversion';
|
||||||
|
default: return alertType ?? 'Alert';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('dismiss-all')
|
||||||
|
async dismissAll() {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const data = await this.platform.query<any>(
|
||||||
|
`{ performanceAlerts(first: 100, filter: { dismissedAt: { is: NULL } }) { edges { node { id } } } }`,
|
||||||
|
);
|
||||||
|
const ids = (data?.performanceAlerts?.edges ?? []).map((e: any) => e.node.id);
|
||||||
|
let dismissed = 0;
|
||||||
|
for (const id of ids) {
|
||||||
|
try {
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation($id: UUID!, $data: PerformanceAlertUpdateInput!) { updatePerformanceAlert(id: $id, data: $data) { id } }`,
|
||||||
|
{ id, data: { dismissedAt: now } },
|
||||||
|
);
|
||||||
|
dismissed++;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return { status: 'ok', dismissed };
|
||||||
|
}
|
||||||
|
}
|
||||||
163
src/supervisor/supervisor-barge.controller.ts
Normal file
163
src/supervisor/supervisor-barge.controller.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { Controller, Post, Get, Body, HttpException, Logger } from '@nestjs/common';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { OzonetelAdminAuthService } from '../ozonetel/ozonetel-admin-auth.service';
|
||||||
|
import { SupervisorService } from './supervisor.service';
|
||||||
|
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||||
|
|
||||||
|
// Supervisor barge/whisper/listen endpoints.
|
||||||
|
// Proxies requests to Ozonetel's dashboardApi using admin JWT auth.
|
||||||
|
//
|
||||||
|
// API reference (from CA-Admin source code):
|
||||||
|
// apiId 63 → CALL_BARGEIN (initiate barge)
|
||||||
|
// apiId 158 → Redis barge state (insert/delete)
|
||||||
|
// apiId 139 → SIP credential pool (sipSubscribe)
|
||||||
|
|
||||||
|
@Controller('api/supervisor/barge')
|
||||||
|
export class SupervisorBargeController {
|
||||||
|
private readonly logger = new Logger(SupervisorBargeController.name);
|
||||||
|
private readonly dashboardApiUrl = 'https://api.cloudagent.ozonetel.com/dashboardApi/monitor/api';
|
||||||
|
private readonly adminApiUrl = 'https://api.cloudagent.ozonetel.com/ca-admin-Api/CloudAgentAPI';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly adminAuth: OzonetelAdminAuthService,
|
||||||
|
private readonly supervisor: SupervisorService,
|
||||||
|
private readonly telephony: TelephonyConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('sip-credentials')
|
||||||
|
async getSipCredentials() {
|
||||||
|
if (!this.adminAuth.isConfigured()) {
|
||||||
|
throw new HttpException('Ozonetel admin not configured — add credentials in Settings → Telephony', 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = this.telephony.getConfig();
|
||||||
|
const sipGateway = `${config.sip.domain}:${config.sip.wsPort}`;
|
||||||
|
const headers = await this.adminAuth.getAuthHeaders();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await axios.post(`${this.adminApiUrl}/endpoint/sipnumber/sipSubscribe`, {
|
||||||
|
apiId: 139,
|
||||||
|
sipURL: sipGateway,
|
||||||
|
}, { headers });
|
||||||
|
|
||||||
|
const data = res.data;
|
||||||
|
this.logger.log(`[BARGE] SIP credentials response: ${JSON.stringify(data)}`);
|
||||||
|
|
||||||
|
if (!data?.sip_number) {
|
||||||
|
throw new HttpException('No SIP numbers available in pool', 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sipNumber: data.sip_number,
|
||||||
|
sipPassword: data.password,
|
||||||
|
sipDomain: data.pop_location ?? config.sip.domain,
|
||||||
|
sipPort: config.sip.wsPort,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[BARGE] SIP credentials failed: ${err.message}`);
|
||||||
|
if (err instanceof HttpException) throw err;
|
||||||
|
throw new HttpException('Failed to fetch SIP credentials', 502);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async initiateBarge(@Body() body: { ucid: string; agentId: string; agentNumber: string; supervisorId?: string }) {
|
||||||
|
if (!body.ucid || !body.agentNumber) {
|
||||||
|
throw new HttpException('ucid and agentNumber required', 400);
|
||||||
|
}
|
||||||
|
if (!this.adminAuth.isConfigured()) {
|
||||||
|
throw new HttpException('Ozonetel admin not configured — add credentials in Settings → Telephony', 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent double-barge on same agent
|
||||||
|
const existing = this.supervisor.getBargeSession(body.agentId);
|
||||||
|
if (existing) {
|
||||||
|
throw new HttpException(`Agent ${body.agentId} is already being monitored`, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get SIP credentials from Ozonetel pool
|
||||||
|
const sipCreds = await this.getSipCredentials();
|
||||||
|
const headers = await this.adminAuth.getAuthHeaders();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await axios.post(this.dashboardApiUrl, {
|
||||||
|
apiId: 63,
|
||||||
|
ucid: body.ucid,
|
||||||
|
action: 'CALL_BARGEIN',
|
||||||
|
isSip: true,
|
||||||
|
phoneno: sipCreds.sipNumber,
|
||||||
|
agentNumber: body.agentNumber,
|
||||||
|
cbURL: 'helix-engage',
|
||||||
|
}, { headers });
|
||||||
|
|
||||||
|
this.logger.log(`[BARGE] Initiated: ucid=${body.ucid} agent=${body.agentId} sip=${sipCreds.sipNumber} response=${JSON.stringify(res.data)}`);
|
||||||
|
|
||||||
|
// Track the session
|
||||||
|
this.supervisor.startBargeSession({
|
||||||
|
supervisorId: body.supervisorId ?? 'admin',
|
||||||
|
agentId: body.agentId,
|
||||||
|
sipNumber: sipCreds.sipNumber,
|
||||||
|
mode: 'listen',
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
...sipCreds,
|
||||||
|
ozonetelResponse: res.data,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[BARGE] Initiation failed: ${err.message} ${err.response?.data ? JSON.stringify(err.response.data) : ''}`);
|
||||||
|
throw new HttpException(`Barge failed: ${err.response?.data?.Message ?? err.message}`, 502);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('mode')
|
||||||
|
async updateMode(@Body() body: { agentId: string; mode: 'listen' | 'whisper' | 'barge' }) {
|
||||||
|
if (!body.agentId || !body.mode) {
|
||||||
|
throw new HttpException('agentId and mode required', 400);
|
||||||
|
}
|
||||||
|
if (!['listen', 'whisper', 'barge'].includes(body.mode)) {
|
||||||
|
throw new HttpException('mode must be listen, whisper, or barge', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = this.supervisor.getBargeSession(body.agentId);
|
||||||
|
if (!session) {
|
||||||
|
throw new HttpException('No active barge session for this agent', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.supervisor.updateBargeMode(body.agentId, body.mode);
|
||||||
|
return { status: 'ok', mode: body.mode };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('end')
|
||||||
|
async endBarge(@Body() body: { agentId: string }) {
|
||||||
|
if (!body.agentId) {
|
||||||
|
throw new HttpException('agentId required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = this.supervisor.getBargeSession(body.agentId);
|
||||||
|
if (!session) {
|
||||||
|
return { status: 'ok', message: 'No active session' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear Redis tracking on Ozonetel side (best-effort)
|
||||||
|
if (this.adminAuth.isConfigured()) {
|
||||||
|
try {
|
||||||
|
const headers = await this.adminAuth.getAuthHeaders();
|
||||||
|
await axios.post(this.dashboardApiUrl, {
|
||||||
|
apiId: 158,
|
||||||
|
Action: 'delete',
|
||||||
|
AgentId: body.agentId,
|
||||||
|
Sip: session.sipNumber,
|
||||||
|
}, { headers });
|
||||||
|
this.logger.log(`[BARGE] Redis cleanup: ${body.agentId}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`[BARGE] Redis cleanup failed (non-critical): ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.supervisor.endBargeSession(body.agentId);
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Post, Body, Query, Sse, Logger } from '@nestjs/common';
|
import { Controller, Get, Post, Body, Query, Sse, Logger } from '@nestjs/common';
|
||||||
import { Observable, filter, map } from 'rxjs';
|
import { Observable, filter, map } from 'rxjs';
|
||||||
import { SupervisorService } from './supervisor.service';
|
import { SupervisorService } from './supervisor.service';
|
||||||
|
import { LogStreamService } from '../logging/log-stream.service';
|
||||||
|
|
||||||
@Controller('api/supervisor')
|
@Controller('api/supervisor')
|
||||||
export class SupervisorController {
|
export class SupervisorController {
|
||||||
@@ -13,6 +14,16 @@ export class SupervisorController {
|
|||||||
return this.supervisor.getActiveCalls();
|
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')
|
@Get('team-performance')
|
||||||
async getTeamPerformance(@Query('date') date?: string) {
|
async getTeamPerformance(@Query('date') date?: string) {
|
||||||
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
||||||
@@ -52,4 +63,33 @@ export class SupervisorController {
|
|||||||
} as MessageEvent)),
|
} 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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { PlatformModule } from '../platform/platform.module';
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
||||||
import { SupervisorController } from './supervisor.controller';
|
import { SupervisorController } from './supervisor.controller';
|
||||||
|
import { SupervisorBargeController } from './supervisor-barge.controller';
|
||||||
|
import { PerformanceAlertsController } from './performance-alerts.controller';
|
||||||
import { SupervisorService } from './supervisor.service';
|
import { SupervisorService } from './supervisor.service';
|
||||||
|
import { AgentHistoryService } from './agent-history.service';
|
||||||
|
import { OzonetelAdminAuthService } from '../ozonetel/ozonetel-admin-auth.service';
|
||||||
|
|
||||||
|
// Note: TelephonyConfigService is available without import because
|
||||||
|
// ConfigThemeModule is @Global(). Do NOT import ConfigThemeModule here
|
||||||
|
// — it causes a circular dependency via AuthModule.
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule, OzonetelAgentModule],
|
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule)],
|
||||||
controllers: [SupervisorController],
|
controllers: [SupervisorController, SupervisorBargeController, PerformanceAlertsController],
|
||||||
providers: [SupervisorService],
|
providers: [SupervisorService, AgentHistoryService, OzonetelAdminAuthService],
|
||||||
exports: [SupervisorService],
|
exports: [SupervisorService, AgentHistoryService, OzonetelAdminAuthService],
|
||||||
})
|
})
|
||||||
export class SupervisorModule {}
|
export class SupervisorModule {}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
||||||
|
import { AgentHistoryService, AgentEventType } from './agent-history.service';
|
||||||
|
|
||||||
type ActiveCall = {
|
type ActiveCall = {
|
||||||
ucid: string;
|
ucid: string;
|
||||||
@@ -20,23 +21,63 @@ type AgentStateEntry = {
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ACW auto-dispose: if an agent has been in ACW for longer than this
|
||||||
|
// without the frontend calling /api/ozonetel/dispose, the server
|
||||||
|
// auto-disposes with a default disposition + autoRelease. This is the
|
||||||
|
// Layer 3 safety net — covers browser crash, tab close, page refresh
|
||||||
|
// where sendBeacon didn't fire, or any other frontend failure.
|
||||||
|
const ACW_TIMEOUT_MS = 30_000; // 30 seconds
|
||||||
|
const ACW_DEFAULT_DISPOSITION = 'General Enquiry';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SupervisorService implements OnModuleInit {
|
export class SupervisorService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(SupervisorService.name);
|
private readonly logger = new Logger(SupervisorService.name);
|
||||||
private readonly activeCalls = new Map<string, ActiveCall>();
|
private readonly activeCalls = new Map<string, ActiveCall>();
|
||||||
private readonly agentStates = new Map<string, AgentStateEntry>();
|
private readonly agentStates = new Map<string, AgentStateEntry>();
|
||||||
readonly agentStateSubject = new Subject<{ agentId: string; state: AgentOzonetelState; timestamp: string }>();
|
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, {
|
||||||
|
supervisorId: string;
|
||||||
|
agentId: string;
|
||||||
|
sipNumber: string;
|
||||||
|
mode: 'listen' | 'whisper' | 'barge';
|
||||||
|
startedAt: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private platform: PlatformGraphqlService,
|
private platform: PlatformGraphqlService,
|
||||||
private ozonetel: OzonetelAgentService,
|
private ozonetel: OzonetelAgentService,
|
||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
|
private history: AgentHistoryService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
this.logger.log('Supervisor service initialized');
|
this.logger.log('Supervisor service initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Called by the dispose endpoint to cancel the ACW timer
|
||||||
|
// (agent submitted disposition before the timeout)
|
||||||
|
cancelAcwTimer(agentId: string) {
|
||||||
|
const timer = this.acwTimers.get(agentId);
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
this.acwTimers.delete(agentId);
|
||||||
|
this.logger.log(`[ACW-TIMER] Cancelled for ${agentId} (disposition received)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleCallEvent(event: any) {
|
handleCallEvent(event: any) {
|
||||||
const action = event.action;
|
const action = event.action;
|
||||||
const ucid = event.ucid ?? event.monitorUCID;
|
const ucid = event.ucid ?? event.monitorUCID;
|
||||||
@@ -44,37 +85,171 @@ export class SupervisorService implements OnModuleInit {
|
|||||||
const callerNumber = event.caller_id ?? event.callerID;
|
const callerNumber = event.caller_id ?? event.callerID;
|
||||||
const callType = event.call_type ?? event.Type;
|
const callType = event.call_type ?? event.Type;
|
||||||
const eventTime = event.event_time ?? event.eventTime ?? new Date().toISOString();
|
const eventTime = event.event_time ?? event.eventTime ?? new Date().toISOString();
|
||||||
|
const iso = this.parseOzonetelTime(eventTime);
|
||||||
|
|
||||||
if (!ucid) return;
|
if (!ucid) return;
|
||||||
|
|
||||||
if (action === 'Answered' || action === 'Calling') {
|
if (action === 'Answered' || action === 'Calling') {
|
||||||
this.activeCalls.set(ucid, {
|
// Don't show calls for offline agents (ghost calls)
|
||||||
ucid, agentId, callerNumber,
|
const agentState = this.agentStates.get(agentId);
|
||||||
callType, startTime: eventTime, status: 'active',
|
if (agentState?.state === 'offline') {
|
||||||
});
|
this.logger.warn(`Ignoring call event for offline agent ${agentId} (${ucid})`);
|
||||||
this.logger.log(`Active call: ${agentId} ↔ ${callerNumber} (${ucid})`);
|
return;
|
||||||
} else if (action === 'Disconnect') {
|
|
||||||
this.activeCalls.delete(ucid);
|
|
||||||
this.logger.log(`Call ended: ${ucid}`);
|
|
||||||
}
|
}
|
||||||
|
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
|
||||||
|
// (that's when busy-time actually begins). "Calling" is the
|
||||||
|
// ring — doesn't count as busy.
|
||||||
|
if (action === 'Answered' && agentId) {
|
||||||
|
this.history.persistAgentEvent({
|
||||||
|
ozonetelAgentId: agentId,
|
||||||
|
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.
|
||||||
|
if (wasActive?.agentId) {
|
||||||
|
this.history.persistAgentEvent({
|
||||||
|
ozonetelAgentId: wasActive.agentId,
|
||||||
|
eventType: 'CALL_END',
|
||||||
|
eventAt: iso,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ozonetel sends timestamps in "YYYY-MM-DD HH:MM:SS" IST format. Normalise.
|
||||||
|
private parseOzonetelTime(raw: string): string {
|
||||||
|
if (!raw) return new Date().toISOString();
|
||||||
|
const asDate = new Date(raw);
|
||||||
|
if (!isNaN(asDate.getTime())) return asDate.toISOString();
|
||||||
|
return new Date().toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleAgentEvent(event: any) {
|
handleAgentEvent(event: any) {
|
||||||
const agentId = event.agentId ?? event.agent_id ?? 'unknown';
|
const agentId = event.agentId ?? event.agent_id ?? 'unknown';
|
||||||
const action = event.action ?? 'unknown';
|
const action = event.action ?? 'unknown';
|
||||||
const eventData = event.eventData ?? '';
|
const eventData = event.eventData ?? event.data ?? '';
|
||||||
|
const pauseReason = event.pauseReason ?? event.pause_reason ?? event.breakReason ?? '';
|
||||||
const eventTime = event.event_time ?? event.eventTime ?? new Date().toISOString();
|
const eventTime = event.event_time ?? event.eventTime ?? new Date().toISOString();
|
||||||
this.logger.log(`[AGENT-STATE] ${agentId} → ${action}${eventData ? ` (${eventData})` : ''} at ${eventTime}`);
|
this.logger.log(`[AGENT-STATE] ${agentId} → ${action} eventData="${eventData}" pauseReason="${pauseReason}" at ${eventTime}`);
|
||||||
|
this.logger.log(`[AGENT-STATE] Full event payload: ${JSON.stringify(event)}`);
|
||||||
|
|
||||||
const mapped = this.mapOzonetelAction(action, eventData);
|
const priorState = this.agentStates.get(agentId)?.state;
|
||||||
|
const mapped = this.mapOzonetelAction(action, eventData, pauseReason);
|
||||||
if (mapped) {
|
if (mapped) {
|
||||||
this.agentStates.set(agentId, { state: mapped, timestamp: eventTime });
|
this.agentStates.set(agentId, { state: mapped, timestamp: eventTime });
|
||||||
this.agentStateSubject.next({ agentId, state: mapped, timestamp: eventTime });
|
this.agentStateSubject.next({ agentId, state: mapped, timestamp: eventTime });
|
||||||
this.logger.log(`[AGENT-STATE] Emitted: ${agentId} → ${mapped}`);
|
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(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 3: ACW auto-dispose safety net
|
||||||
|
if (mapped === 'acw') {
|
||||||
|
// Find the most recent UCID for this agent
|
||||||
|
const lastCall = Array.from(this.activeCalls.values())
|
||||||
|
.filter(c => c.agentId === agentId)
|
||||||
|
.pop();
|
||||||
|
const ucid = lastCall?.ucid;
|
||||||
|
|
||||||
|
this.cancelAcwTimer(agentId); // clear any existing timer
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
// Check if agent is STILL in ACW (they might have disposed by now)
|
||||||
|
const current = this.agentStates.get(agentId);
|
||||||
|
if (current?.state !== 'acw') {
|
||||||
|
this.logger.log(`[ACW-TIMER] ${agentId} no longer in ACW — skipping auto-dispose`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.logger.warn(`[ACW-TIMER] ${agentId} stuck in ACW for ${ACW_TIMEOUT_MS / 1000}s — auto-disposing${ucid ? ` (UCID ${ucid})` : ''}`);
|
||||||
|
try {
|
||||||
|
if (ucid) {
|
||||||
|
await this.ozonetel.setDisposition({ agentId, ucid, disposition: ACW_DEFAULT_DISPOSITION });
|
||||||
|
} else {
|
||||||
|
await this.ozonetel.changeAgentState({ agentId, state: 'Ready' });
|
||||||
|
}
|
||||||
|
this.logger.log(`[ACW-TIMER] Auto-dispose successful for ${agentId}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[ACW-TIMER] Auto-dispose failed for ${agentId}: ${err.message}`);
|
||||||
|
// Last resort: try force-ready
|
||||||
|
try {
|
||||||
|
await this.ozonetel.changeAgentState({ agentId, state: 'Ready' });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
this.acwTimers.delete(agentId);
|
||||||
|
}, ACW_TIMEOUT_MS);
|
||||||
|
this.acwTimers.set(agentId, timer);
|
||||||
|
this.logger.log(`[ACW-TIMER] Started ${ACW_TIMEOUT_MS / 1000}s timer for ${agentId}`);
|
||||||
|
} else if (mapped === 'ready' || mapped === 'offline') {
|
||||||
|
// Agent left ACW normally — cancel the timer
|
||||||
|
this.cancelAcwTimer(agentId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapOzonetelAction(action: string, eventData: string): AgentOzonetelState | null {
|
// Map the Ozonetel webhook action to our AgentEvent.eventType enum.
|
||||||
|
// 'release' means "agent is available again" — could be post-pause,
|
||||||
|
// post-ACW, or post-call. Use the previous agent state to emit the
|
||||||
|
// specific close-out event so session rollups can sum durations by
|
||||||
|
// category (pause vs wrapup vs busy) without extra metadata.
|
||||||
|
private mapToHistoryEventType(action: string, priorState: AgentOzonetelState | undefined): AgentEventType | null {
|
||||||
|
switch (action) {
|
||||||
|
case 'login': return 'LOGIN';
|
||||||
|
case 'logout': return 'LOGOUT';
|
||||||
|
case 'ACW': return 'ACW_START';
|
||||||
|
case 'pause':
|
||||||
|
case 'AUX':
|
||||||
|
return 'PAUSE';
|
||||||
|
case 'release':
|
||||||
|
case 'IDLE':
|
||||||
|
if (priorState === 'acw') return 'ACW_END';
|
||||||
|
if (priorState === 'break' || priorState === 'training') return 'RESUME';
|
||||||
|
return 'READY';
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapOzonetelAction(action: string, eventData: string, pauseReason?: string): AgentOzonetelState | null {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'release': return 'ready';
|
case 'release': return 'ready';
|
||||||
case 'IDLE': return 'ready'; // agent available after unanswered/canceled call
|
case 'IDLE': return 'ready'; // agent available after unanswered/canceled call
|
||||||
@@ -82,11 +257,16 @@ export class SupervisorService implements OnModuleInit {
|
|||||||
case 'incall': return 'in-call';
|
case 'incall': return 'in-call';
|
||||||
case 'ACW': return 'acw';
|
case 'ACW': return 'acw';
|
||||||
case 'logout': return 'offline';
|
case 'logout': return 'offline';
|
||||||
case 'AUX':
|
case 'pause': // Ozonetel sends 'pause' via webhook when agent is paused
|
||||||
|
case 'AUX': {
|
||||||
// "changeMode" is the brief AUX during login — not a real pause
|
// "changeMode" is the brief AUX during login — not a real pause
|
||||||
if (eventData === 'changeMode') return null;
|
if (eventData === 'changeMode') return null;
|
||||||
if (eventData?.toLowerCase().includes('training')) return 'training';
|
// Check pauseReason first (explicit field), then fall back to eventData
|
||||||
|
const reason = (pauseReason || eventData || '').toLowerCase();
|
||||||
|
this.logger.log(`[AGENT-STATE] Pause reason resolved: "${reason}"`);
|
||||||
|
if (reason.includes('training')) return 'training';
|
||||||
return 'break';
|
return 'break';
|
||||||
|
}
|
||||||
case 'login': return null; // wait for release
|
case 'login': return null; // wait for release
|
||||||
default: return null;
|
default: return null;
|
||||||
}
|
}
|
||||||
@@ -103,34 +283,269 @@ export class SupervisorService implements OnModuleInit {
|
|||||||
this.agentStateSubject.next({ agentId, state: 'force-logout' as any, timestamp: new Date().toISOString() });
|
this.agentStateSubject.next({ agentId, state: 'force-logout' as any, timestamp: new Date().toISOString() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Max plausible call length before the entry is treated as orphaned.
|
||||||
|
// Real Ozonetel calls cap out far short of this — 30 minutes is a safe
|
||||||
|
// ceiling for a hospital call-center context. If a genuinely longer
|
||||||
|
// call existed, losing it from Live Monitor is preferable to the ghost
|
||||||
|
// state (supervisors lose trust in the dashboard otherwise).
|
||||||
|
private static readonly MAX_ACTIVE_CALL_AGE_MS = 30 * 60 * 1000;
|
||||||
|
|
||||||
|
// Agent states that are incompatible with having an active call. If the
|
||||||
|
// mapped agent is currently in one of these, the activeCalls entry is
|
||||||
|
// 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[] {
|
getActiveCalls(): ActiveCall[] {
|
||||||
|
// Sweep stale entries before returning. The activeCalls Map is a
|
||||||
|
// best-effort in-memory projection of Ozonetel call events — if
|
||||||
|
// Ozonetel drops a Disconnect (network blip, subscription hiccup,
|
||||||
|
// sidecar restart mid-call), the entry lingers forever and the
|
||||||
|
// Live Call Monitor shows a ghost call with a runaway timer.
|
||||||
|
//
|
||||||
|
// Two signals identify staleness:
|
||||||
|
// 1. The associated agent is not in a busy state (ready, offline,
|
||||||
|
// paused — they can't be on a call).
|
||||||
|
// 2. startTime is older than MAX_ACTIVE_CALL_AGE_MS (hard ceiling
|
||||||
|
// regardless of agent-state signal).
|
||||||
|
const now = Date.now();
|
||||||
|
const toDelete: string[] = [];
|
||||||
|
|
||||||
|
for (const [ucid, call] of this.activeCalls.entries()) {
|
||||||
|
const ageMs = now - new Date(call.startTime).getTime();
|
||||||
|
if (isNaN(ageMs)) continue;
|
||||||
|
|
||||||
|
if (ageMs > SupervisorService.MAX_ACTIVE_CALL_AGE_MS) {
|
||||||
|
toDelete.push(ucid);
|
||||||
|
this.logger.warn(`[ACTIVE-CALLS] Sweep: dropping ${ucid} (age ${Math.round(ageMs / 60000)}m, exceeds ${SupervisorService.MAX_ACTIVE_CALL_AGE_MS / 60000}m cap)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentState = this.agentStates.get(call.agentId)?.state;
|
||||||
|
if (agentState && SupervisorService.NON_CALL_AGENT_STATES.has(agentState)) {
|
||||||
|
toDelete.push(ucid);
|
||||||
|
this.logger.warn(`[ACTIVE-CALLS] Sweep: dropping ${ucid} — agent ${call.agentId} is ${agentState}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ucid of toDelete) this.activeCalls.delete(ucid);
|
||||||
|
|
||||||
return Array.from(this.activeCalls.values());
|
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> {
|
async getTeamPerformance(date: string): Promise<any> {
|
||||||
// Get all agents from platform
|
// Get all agents from platform. Field names are label-derived
|
||||||
|
// camelCase on the current platform schema — see
|
||||||
|
// agent-config.service.ts for the canonical explanation of the
|
||||||
|
// legacy lowercase names that used to exist on staging.
|
||||||
const agentData = await this.platform.query<any>(
|
const agentData = await this.platform.query<any>(
|
||||||
`{ agents(first: 20) { edges { node {
|
`{ agents(first: 20) { edges { node {
|
||||||
id name ozonetelagentid npsscore
|
id name ozonetelAgentId npsScore
|
||||||
maxidleminutes minnpsthreshold minconversionpercent
|
maxIdleMinutes minNpsThreshold minConversion
|
||||||
} } } }`,
|
} } } }`,
|
||||||
);
|
);
|
||||||
const agents = agentData?.agents?.edges?.map((e: any) => e.node) ?? [];
|
const agents = agentData?.agents?.edges?.map((e: any) => e.node) ?? [];
|
||||||
|
|
||||||
// Fetch Ozonetel time summary per agent
|
// Fetch AgentSession rows for this date — the authoritative source
|
||||||
|
// for time breakdowns now that Phase 2 ingest is live. Keyed by
|
||||||
|
// agentId (UUID on platform) so we can match back by agent.id.
|
||||||
|
const sessionByAgentId = await this.fetchAgentSessionsByDate(date);
|
||||||
|
|
||||||
|
// Fetch CDR for the entire account for this date (one call, not per-agent)
|
||||||
|
let allCdr: any[] = [];
|
||||||
|
try {
|
||||||
|
allCdr = await this.ozonetel.fetchCDR({ date });
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to fetch CDR for ${date}: ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge AgentSession → timeBreakdown (Ozonetel shape for UI compat);
|
||||||
|
// fall back to Ozonetel summary when no session row exists.
|
||||||
const summaries = await Promise.all(
|
const summaries = await Promise.all(
|
||||||
agents.map(async (agent: any) => {
|
agents.map(async (agent: any) => {
|
||||||
if (!agent.ozonetelagentid) return { ...agent, timeBreakdown: null };
|
if (!agent.ozonetelAgentId) return { ...agent, timeBreakdown: null, calls: null };
|
||||||
try {
|
try {
|
||||||
const summary = await this.ozonetel.getAgentSummary(agent.ozonetelagentid, date);
|
let timeBreakdown: any = null;
|
||||||
return { ...agent, timeBreakdown: summary };
|
let source: 'AGENT_SESSION' | 'OZONETEL_SUMMARY' | 'NONE' = 'NONE';
|
||||||
|
|
||||||
|
const session = sessionByAgentId.get(agent.id);
|
||||||
|
if (session) {
|
||||||
|
timeBreakdown = this.sessionToTimeBreakdown(session);
|
||||||
|
source = 'AGENT_SESSION';
|
||||||
|
} else {
|
||||||
|
timeBreakdown = await this.ozonetel.getAgentSummary(agent.ozonetelAgentId, date);
|
||||||
|
if (timeBreakdown) source = 'OZONETEL_SUMMARY';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter CDR to this agent
|
||||||
|
const agentCdr = allCdr.filter(
|
||||||
|
(c: any) => c.AgentID === agent.ozonetelAgentId || c.AgentName === agent.ozonetelAgentId,
|
||||||
|
);
|
||||||
|
const totalCalls = agentCdr.length;
|
||||||
|
const inbound = agentCdr.filter((c: any) => c.Type === 'InBound').length;
|
||||||
|
const outbound = agentCdr.filter((c: any) => c.Type === 'Manual' || c.Type === 'Progressive').length;
|
||||||
|
const answered = agentCdr.filter((c: any) => c.Status === 'Answered').length;
|
||||||
|
const missed = agentCdr.filter((c: any) => c.Status === 'NotAnswered').length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...agent,
|
||||||
|
timeBreakdown,
|
||||||
|
timeBreakdownSource: source,
|
||||||
|
calls: { total: totalCalls, inbound, outbound, answered, missed },
|
||||||
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Failed to get summary for ${agent.ozonetelagentid}: ${err}`);
|
this.logger.warn(`Failed to get summary for ${agent.ozonetelAgentId}: ${err}`);
|
||||||
return { ...agent, timeBreakdown: null };
|
return { ...agent, timeBreakdown: null, calls: null };
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return { date, agents: summaries };
|
// Aggregate team totals
|
||||||
|
const teamTotals = {
|
||||||
|
totalCalls: summaries.reduce((sum, a) => sum + (a.calls?.total ?? 0), 0),
|
||||||
|
inbound: summaries.reduce((sum, a) => sum + (a.calls?.inbound ?? 0), 0),
|
||||||
|
outbound: summaries.reduce((sum, a) => sum + (a.calls?.outbound ?? 0), 0),
|
||||||
|
answered: summaries.reduce((sum, a) => sum + (a.calls?.answered ?? 0), 0),
|
||||||
|
missed: summaries.reduce((sum, a) => sum + (a.calls?.missed ?? 0), 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
return { date, agents: summaries, teamTotals };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull AgentSession rows for the given IST date, keyed by agent UUID so
|
||||||
|
// getTeamPerformance can look them up per-agent.
|
||||||
|
private async fetchAgentSessionsByDate(date: string): Promise<Map<string, any>> {
|
||||||
|
const map = new Map<string, any>();
|
||||||
|
try {
|
||||||
|
const data = await this.platform.query<any>(
|
||||||
|
`{ agentSessions(first: 100, filter: { date: { eq: "${date}" } }) {
|
||||||
|
edges { node {
|
||||||
|
agentId loginDurationS busyTimeS idleTimeS pauseTimeS
|
||||||
|
wrapupTimeS dialTimeS avgHandlingTimeS source lastSyncedAt
|
||||||
|
} }
|
||||||
|
} }`,
|
||||||
|
);
|
||||||
|
const edges = data?.agentSessions?.edges ?? [];
|
||||||
|
for (const e of edges) {
|
||||||
|
if (e.node?.agentId) map.set(e.node.agentId, e.node);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[PERF] Failed to fetch AgentSession rows for ${date}: ${err}`);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render AgentSession seconds in the HH:MM:SS shape the frontend expects
|
||||||
|
// (matches Ozonetel's summary so team-performance.tsx can parseTime() it
|
||||||
|
// without changing the page code).
|
||||||
|
private sessionToTimeBreakdown(session: any): any {
|
||||||
|
const hms = (sec: number | null | undefined): string => {
|
||||||
|
const s = Math.max(0, Math.round(sec ?? 0));
|
||||||
|
const h = Math.floor(s / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
const r = s % 60;
|
||||||
|
return `${h}:${String(m).padStart(2, '0')}:${String(r).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
totalLoginTime: hms(session.loginDurationS),
|
||||||
|
totalBusyTime: hms(session.busyTimeS),
|
||||||
|
totalIdleTime: hms(session.idleTimeS),
|
||||||
|
totalPauseTime: hms(session.pauseTimeS),
|
||||||
|
totalWrapupTime: hms(session.wrapupTimeS),
|
||||||
|
totalDialTime: hms(session.dialTimeS),
|
||||||
|
avgHandlingTime: hms(session.avgHandlingTimeS),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Barge session management ---
|
||||||
|
|
||||||
|
getBargeSession(agentId: string) {
|
||||||
|
return this.bargeSessions.get(agentId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
startBargeSession(session: { supervisorId: string; agentId: string; sipNumber: string; mode: 'listen' | 'whisper' | 'barge'; startedAt: string }) {
|
||||||
|
this.bargeSessions.set(session.agentId, session);
|
||||||
|
this.logger.log(`[BARGE] Started: ${session.supervisorId} → ${session.agentId} (${session.mode})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateBargeMode(agentId: string, mode: 'listen' | 'whisper' | 'barge') {
|
||||||
|
const session = this.bargeSessions.get(agentId);
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
const previousMode = session.mode;
|
||||||
|
session.mode = mode;
|
||||||
|
|
||||||
|
// Emit SSE to agent — whisper/barge show indicator, listen is silent
|
||||||
|
if (mode === 'whisper' || mode === 'barge') {
|
||||||
|
this.agentStateSubject.next({ agentId, state: `supervisor-${mode}`, timestamp: new Date().toISOString() });
|
||||||
|
} else if (previousMode !== 'listen') {
|
||||||
|
// Switching back to listen from whisper/barge
|
||||||
|
this.agentStateSubject.next({ agentId, state: 'supervisor-left', timestamp: new Date().toISOString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[BARGE] Mode: ${agentId} → ${mode}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
endBargeSession(agentId: string) {
|
||||||
|
const session = this.bargeSessions.get(agentId);
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
this.bargeSessions.delete(agentId);
|
||||||
|
this.agentStateSubject.next({ agentId, state: 'supervisor-left', timestamp: new Date().toISOString() });
|
||||||
|
this.logger.log(`[BARGE] Ended: ${session.supervisorId} → ${agentId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
39
src/team/team.controller.ts
Normal file
39
src/team/team.controller.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
TeamService,
|
||||||
|
type CreateMemberInput,
|
||||||
|
type CreatedMember,
|
||||||
|
type UpdateMemberInput,
|
||||||
|
} from './team.service';
|
||||||
|
|
||||||
|
// REST wrapper around TeamService. Mounted at /api/team/*.
|
||||||
|
// The Team wizard step on the frontend posts here instead of firing
|
||||||
|
// the platform's sendInvitations mutation directly.
|
||||||
|
|
||||||
|
@Controller('api/team')
|
||||||
|
export class TeamController {
|
||||||
|
constructor(private team: TeamService) {}
|
||||||
|
|
||||||
|
@Post('members')
|
||||||
|
async createMember(@Body() body: CreateMemberInput): Promise<CreatedMember> {
|
||||||
|
return this.team.createMember(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('members/:id')
|
||||||
|
async updateMember(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: UpdateMemberInput,
|
||||||
|
): Promise<{ id: string }> {
|
||||||
|
return this.team.updateMember(id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the cached plaintext temp password for a recently-created
|
||||||
|
// member if it's still within its 24h TTL, or { password: null }
|
||||||
|
// on cache miss. Used by the wizard's right-pane copy icon when
|
||||||
|
// its in-browser memory was wiped by a refresh.
|
||||||
|
@Get('members/:id/temp-password')
|
||||||
|
async getTempPassword(@Param('id') id: string): Promise<{ password: string | null }> {
|
||||||
|
const password = await this.team.getTempPassword(id);
|
||||||
|
return { password };
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/team/team.module.ts
Normal file
16
src/team/team.module.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { TeamController } from './team.controller';
|
||||||
|
import { TeamService } from './team.service';
|
||||||
|
|
||||||
|
// AuthModule is imported because TeamService uses SessionService for
|
||||||
|
// its generic Redis cache (storing recently-created temp passwords
|
||||||
|
// with a 24h TTL so the right pane's copy icon survives a reload).
|
||||||
|
@Module({
|
||||||
|
imports: [PlatformModule, AuthModule],
|
||||||
|
controllers: [TeamController],
|
||||||
|
providers: [TeamService],
|
||||||
|
exports: [TeamService],
|
||||||
|
})
|
||||||
|
export class TeamModule {}
|
||||||
334
src/team/team.service.ts
Normal file
334
src/team/team.service.ts
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { SessionService } from '../auth/session.service';
|
||||||
|
|
||||||
|
// Recently-created temp passwords are cached in Redis under this prefix
|
||||||
|
// for 24 hours so the right pane's copy icon keeps working after a
|
||||||
|
// browser refresh. The plaintext expires automatically — the assumption
|
||||||
|
// is the employee logs in within a day, at which point the password
|
||||||
|
// loses value anyway.
|
||||||
|
const TEMP_PASSWORD_KEY_PREFIX = 'team:tempPassword:';
|
||||||
|
const TEMP_PASSWORD_TTL_SECONDS = 24 * 60 * 60;
|
||||||
|
const tempPasswordKey = (memberId: string) => `${TEMP_PASSWORD_KEY_PREFIX}${memberId}`;
|
||||||
|
|
||||||
|
// In-place employee creation. The platform's sendInvitations flow is
|
||||||
|
// deliberately NOT used — hospital admins create employees from the
|
||||||
|
// portal and hand out credentials directly (see feedback-no-invites in
|
||||||
|
// memory).
|
||||||
|
//
|
||||||
|
// Chain:
|
||||||
|
// 1. Fetch workspace invite hash (workspace-level setting) so
|
||||||
|
// signUpInWorkspace accepts our call — this is the same hash the
|
||||||
|
// public invite link uses but we consume it server-side.
|
||||||
|
// 2. signUpInWorkspace(email, password, workspaceId, workspaceInviteHash)
|
||||||
|
// — creates the core.user row + the workspaceMember row. Returns
|
||||||
|
// a loginToken we throw away (admin has their own session).
|
||||||
|
// 3. Look up the workspaceMember we just created, filtering by
|
||||||
|
// userEmail (the only field we have to go on).
|
||||||
|
// 4. updateWorkspaceMember to set firstName / lastName.
|
||||||
|
// 5. updateWorkspaceMemberRole to assign the role the admin picked.
|
||||||
|
// 6. (optional) updateAgent to link the new member to a SIP seat if
|
||||||
|
// they're a CC agent.
|
||||||
|
//
|
||||||
|
// Errors from any step bubble up as a BadRequestException — the admin
|
||||||
|
// sees the real GraphQL error message, which usually tells them
|
||||||
|
// exactly what went wrong (email already exists, role not assignable,
|
||||||
|
// etc).
|
||||||
|
|
||||||
|
export type CreateMemberInput = {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
roleId: string;
|
||||||
|
// Optional SIP seat link — set when the role is HelixEngage User
|
||||||
|
// (CC agent). Ignored otherwise.
|
||||||
|
agentId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreatedMember = {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
userEmail: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
roleId: string;
|
||||||
|
agentId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update payload — name + role only. Email and password are not
|
||||||
|
// touched (they need separate flows). SIP seat reassignment goes
|
||||||
|
// through the Telephony step's updateAgent path, not here.
|
||||||
|
export type UpdateMemberInput = {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
roleId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TeamService {
|
||||||
|
private readonly logger = new Logger(TeamService.name);
|
||||||
|
// Workspace invite hash is stable for the lifetime of the workspace
|
||||||
|
// — cache it after first fetch so subsequent creates skip the
|
||||||
|
// extra round-trip.
|
||||||
|
private cachedInviteHash: { workspaceId: string; inviteHash: string } | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private platform: PlatformGraphqlService,
|
||||||
|
private session: SessionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createMember(input: CreateMemberInput): Promise<CreatedMember> {
|
||||||
|
const email = input.email.trim().toLowerCase();
|
||||||
|
const firstName = input.firstName.trim();
|
||||||
|
const lastName = input.lastName.trim();
|
||||||
|
|
||||||
|
if (!email || !firstName || !input.password || !input.roleId) {
|
||||||
|
throw new BadRequestException('email, firstName, password and roleId are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1 — fetch workspace id + invite hash
|
||||||
|
const ws = await this.getWorkspaceContext();
|
||||||
|
|
||||||
|
// Step 2 — create the user + workspace member via signUpInWorkspace
|
||||||
|
try {
|
||||||
|
await this.platform.query(
|
||||||
|
`mutation SignUpInWorkspace($email: String!, $password: String!, $workspaceId: UUID!, $workspaceInviteHash: String!) {
|
||||||
|
signUpInWorkspace(
|
||||||
|
email: $email,
|
||||||
|
password: $password,
|
||||||
|
workspaceId: $workspaceId,
|
||||||
|
workspaceInviteHash: $workspaceInviteHash,
|
||||||
|
) {
|
||||||
|
workspace { id }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
email,
|
||||||
|
password: input.password,
|
||||||
|
workspaceId: ws.workspaceId,
|
||||||
|
workspaceInviteHash: ws.inviteHash,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`signUpInWorkspace failed for ${email}: ${err}`);
|
||||||
|
throw new BadRequestException(this.extractGraphqlMessage(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3 — find the workspaceMember that just got created. We
|
||||||
|
// filter by userEmail since that's the only handle we have.
|
||||||
|
// Plural query + client-side pick so we don't rely on a
|
||||||
|
// specific filter shape.
|
||||||
|
const membersData = await this.platform.query<{
|
||||||
|
workspaceMembers: { edges: { node: { id: string; userId: string; userEmail: string } }[] };
|
||||||
|
}>(
|
||||||
|
`{ workspaceMembers { edges { node { id userId userEmail } } } }`,
|
||||||
|
);
|
||||||
|
const member = membersData.workspaceMembers.edges
|
||||||
|
.map((e) => e.node)
|
||||||
|
.find((m) => (m.userEmail ?? '').toLowerCase() === email);
|
||||||
|
if (!member) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Workspace member was created but could not be located — retry in a few seconds',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4 — set their name. Note: the platform's
|
||||||
|
// updateWorkspaceMember mutation declares its `id` arg as
|
||||||
|
// `UUID!` (not `ID!`), and GraphQL refuses to coerce between
|
||||||
|
// those scalars even though both hold the same string value.
|
||||||
|
// Same applies to updateAgent below — verified via __schema
|
||||||
|
// introspection. Pre-existing code in platform-graphql.service
|
||||||
|
// still uses `ID!` for updateLead; that's a separate latent
|
||||||
|
// bug that's untouched here so the diff stays focused on the
|
||||||
|
// team-create failure.
|
||||||
|
try {
|
||||||
|
await this.platform.query(
|
||||||
|
`mutation UpdateWorkspaceMember($id: UUID!, $data: WorkspaceMemberUpdateInput!) {
|
||||||
|
updateWorkspaceMember(id: $id, data: $data) { id }
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
id: member.id,
|
||||||
|
data: {
|
||||||
|
name: { firstName, lastName },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`updateWorkspaceMember name failed for ${member.id}: ${err}`);
|
||||||
|
// Non-fatal — the account exists, just unnamed. Surface it
|
||||||
|
// anyway so the admin can fix in settings.
|
||||||
|
throw new BadRequestException(this.extractGraphqlMessage(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5 — assign role
|
||||||
|
try {
|
||||||
|
await this.platform.query(
|
||||||
|
`mutation AssignRole($workspaceMemberId: UUID!, $roleId: UUID!) {
|
||||||
|
updateWorkspaceMemberRole(workspaceMemberId: $workspaceMemberId, roleId: $roleId) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ workspaceMemberId: member.id, roleId: input.roleId },
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`updateWorkspaceMemberRole failed for ${member.id}: ${err}`);
|
||||||
|
throw new BadRequestException(this.extractGraphqlMessage(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6 — (optional) link SIP seat
|
||||||
|
if (input.agentId) {
|
||||||
|
try {
|
||||||
|
await this.platform.query(
|
||||||
|
`mutation LinkAgent($id: UUID!, $data: AgentUpdateInput!) {
|
||||||
|
updateAgent(id: $id, data: $data) { id workspaceMemberId }
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
id: input.agentId,
|
||||||
|
data: { workspaceMemberId: member.id },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`updateAgent link failed for agent ${input.agentId}: ${err}`);
|
||||||
|
throw new BadRequestException(this.extractGraphqlMessage(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the plaintext temp password in Redis (24h TTL) so the
|
||||||
|
// wizard's right-pane copy icon keeps working after a browser
|
||||||
|
// refresh. The password is also stored hashed on the platform
|
||||||
|
// (used for actual login auth) — this Redis copy exists ONLY
|
||||||
|
// so the admin can recover the plaintext to share with the
|
||||||
|
// employee. Expires automatically; no plaintext persists past
|
||||||
|
// 24h. Trade-off accepted because the plan is to force a
|
||||||
|
// password reset on first login (defense in depth).
|
||||||
|
try {
|
||||||
|
await this.session.setCache(
|
||||||
|
tempPasswordKey(member.id),
|
||||||
|
input.password,
|
||||||
|
TEMP_PASSWORD_TTL_SECONDS,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to cache temp password for ${member.id}: ${err}`);
|
||||||
|
// Non-fatal — admin can still copy from session memory
|
||||||
|
// before page reload. We just lose the post-reload
|
||||||
|
// recovery path for this one member.
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Created member ${email} (id=${member.id}) role=${input.roleId} agent=${input.agentId ?? 'none'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: member.id,
|
||||||
|
userId: member.userId,
|
||||||
|
userEmail: email,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
roleId: input.roleId,
|
||||||
|
agentId: input.agentId ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the cached temp password for a member, if it's still
|
||||||
|
// within its 24h TTL. Returns null on miss (cache expired, member
|
||||||
|
// never created via this endpoint, or Redis unreachable). The
|
||||||
|
// wizard's copy icon falls back to this when the in-browser
|
||||||
|
// memory was wiped by a page reload.
|
||||||
|
async getTempPassword(memberId: string): Promise<string | null> {
|
||||||
|
if (!memberId) return null;
|
||||||
|
try {
|
||||||
|
return await this.session.getCache(tempPasswordKey(memberId));
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to read temp password cache for ${memberId}: ${err}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update an existing workspace member — name + role only.
|
||||||
|
// Mirrors the create path's mutation chain but skips signUp,
|
||||||
|
// member lookup, and the SIP seat link. Errors bubble up as
|
||||||
|
// BadRequestException so the admin sees the real GraphQL message.
|
||||||
|
async updateMember(memberId: string, input: UpdateMemberInput): Promise<{ id: string }> {
|
||||||
|
const firstName = input.firstName.trim();
|
||||||
|
const lastName = input.lastName.trim();
|
||||||
|
|
||||||
|
if (!memberId || !firstName || !input.roleId) {
|
||||||
|
throw new BadRequestException('memberId, firstName and roleId are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1 — set their name
|
||||||
|
try {
|
||||||
|
await this.platform.query(
|
||||||
|
`mutation UpdateWorkspaceMember($id: UUID!, $data: WorkspaceMemberUpdateInput!) {
|
||||||
|
updateWorkspaceMember(id: $id, data: $data) { id }
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
id: memberId,
|
||||||
|
data: {
|
||||||
|
name: { firstName, lastName },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`updateWorkspaceMember name failed for ${memberId}: ${err}`);
|
||||||
|
throw new BadRequestException(this.extractGraphqlMessage(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2 — assign role (idempotent — same call as the create
|
||||||
|
// path so changing role from X to X is a no-op).
|
||||||
|
try {
|
||||||
|
await this.platform.query(
|
||||||
|
`mutation AssignRole($workspaceMemberId: UUID!, $roleId: UUID!) {
|
||||||
|
updateWorkspaceMemberRole(workspaceMemberId: $workspaceMemberId, roleId: $roleId) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ workspaceMemberId: memberId, roleId: input.roleId },
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`updateWorkspaceMemberRole failed for ${memberId}: ${err}`);
|
||||||
|
throw new BadRequestException(this.extractGraphqlMessage(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Updated member ${memberId} (name="${firstName} ${lastName}", role=${input.roleId})`);
|
||||||
|
return { id: memberId };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getWorkspaceContext(): Promise<{ workspaceId: string; inviteHash: string }> {
|
||||||
|
if (this.cachedInviteHash) return this.cachedInviteHash;
|
||||||
|
const data = await this.platform.query<{
|
||||||
|
currentWorkspace: {
|
||||||
|
id: string;
|
||||||
|
inviteHash: string;
|
||||||
|
isPublicInviteLinkEnabled: boolean;
|
||||||
|
};
|
||||||
|
}>(`{ currentWorkspace { id inviteHash isPublicInviteLinkEnabled } }`);
|
||||||
|
|
||||||
|
const ws = data.currentWorkspace;
|
||||||
|
if (!ws?.id || !ws?.inviteHash) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Workspace is missing id/inviteHash — cannot create employees in-place',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!ws.isPublicInviteLinkEnabled) {
|
||||||
|
// signUpInWorkspace will reject us without this flag set.
|
||||||
|
// Surface a clear error instead of the platform's opaque
|
||||||
|
// "FORBIDDEN" response.
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Workspace public invite link is disabled — enable it in workspace settings so the server can mint user accounts directly',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.cachedInviteHash = { workspaceId: ws.id, inviteHash: ws.inviteHash };
|
||||||
|
return this.cachedInviteHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractGraphqlMessage(err: unknown): string {
|
||||||
|
const msg = (err as Error)?.message ?? 'Unknown error';
|
||||||
|
// PlatformGraphqlService wraps errors as `GraphQL error: [{...}]`.
|
||||||
|
// Pull out the first message so the admin sees something
|
||||||
|
// meaningful in the toast.
|
||||||
|
const match = msg.match(/"message":"([^"]+)"/);
|
||||||
|
return match ? match[1] : msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
243
src/team/team.spec.ts
Normal file
243
src/team/team.spec.ts
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* Team Service — unit tests
|
||||||
|
*
|
||||||
|
* Tests the in-place workspace member creation flow (no email invites):
|
||||||
|
* 1. signUpInWorkspace → creates user + workspace member
|
||||||
|
* 2. updateWorkspaceMember → set name
|
||||||
|
* 3. updateWorkspaceMemberRole → assign role
|
||||||
|
* 4. (optional) updateAgent → link SIP seat
|
||||||
|
*
|
||||||
|
* Platform GraphQL + SessionService are mocked.
|
||||||
|
*/
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { TeamService, CreateMemberInput } from './team.service';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { SessionService } from '../auth/session.service';
|
||||||
|
|
||||||
|
describe('TeamService', () => {
|
||||||
|
let service: TeamService;
|
||||||
|
let platform: jest.Mocked<PlatformGraphqlService>;
|
||||||
|
let session: jest.Mocked<SessionService>;
|
||||||
|
|
||||||
|
const mockWorkspace = {
|
||||||
|
id: '42424242-1c25-4d02-bf25-6aeccf7ea419',
|
||||||
|
inviteHash: 'test-invite-hash',
|
||||||
|
isPublicInviteLinkEnabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockMemberId = 'member-new-001';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
TeamService,
|
||||||
|
{
|
||||||
|
provide: PlatformGraphqlService,
|
||||||
|
useValue: {
|
||||||
|
query: jest.fn().mockImplementation((query: string) => {
|
||||||
|
if (query.includes('currentWorkspace')) {
|
||||||
|
return Promise.resolve({ currentWorkspace: mockWorkspace });
|
||||||
|
}
|
||||||
|
if (query.includes('signUpInWorkspace')) {
|
||||||
|
return Promise.resolve({ signUpInWorkspace: { workspace: { id: mockWorkspace.id } } });
|
||||||
|
}
|
||||||
|
if (query.includes('workspaceMembers')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
workspaceMembers: {
|
||||||
|
edges: [{
|
||||||
|
node: {
|
||||||
|
id: mockMemberId,
|
||||||
|
userId: 'user-new-001',
|
||||||
|
userEmail: 'ccagent@ramaiahcare.com',
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (query.includes('updateWorkspaceMember')) {
|
||||||
|
return Promise.resolve({ updateWorkspaceMember: { id: mockMemberId } });
|
||||||
|
}
|
||||||
|
if (query.includes('updateWorkspaceMemberRole')) {
|
||||||
|
return Promise.resolve({ updateWorkspaceMemberRole: { id: mockMemberId } });
|
||||||
|
}
|
||||||
|
if (query.includes('updateAgent')) {
|
||||||
|
return Promise.resolve({ updateAgent: { id: 'agent-001', workspaceMemberId: mockMemberId } });
|
||||||
|
}
|
||||||
|
return Promise.resolve({});
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: SessionService,
|
||||||
|
useValue: {
|
||||||
|
setCache: jest.fn().mockResolvedValue(undefined),
|
||||||
|
getCache: jest.fn().mockResolvedValue(null),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get(TeamService);
|
||||||
|
platform = module.get(PlatformGraphqlService);
|
||||||
|
session = module.get(SessionService);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Create member (standard flow) ────────────────────────────
|
||||||
|
|
||||||
|
it('should create a workspace member with correct 5-step flow', async () => {
|
||||||
|
const input: CreateMemberInput = {
|
||||||
|
firstName: 'CC',
|
||||||
|
lastName: 'Agent',
|
||||||
|
email: 'ccagent@ramaiahcare.com',
|
||||||
|
password: 'CcRamaiah@2026',
|
||||||
|
roleId: 'role-agent-001',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await service.createMember(input);
|
||||||
|
|
||||||
|
expect(result.id).toBe(mockMemberId);
|
||||||
|
expect(result.userEmail).toBe('ccagent@ramaiahcare.com');
|
||||||
|
|
||||||
|
// Verify the 5-step call chain
|
||||||
|
const calls = platform.query.mock.calls.map(c => c[0] as string);
|
||||||
|
|
||||||
|
// Step 1: fetch workspace context (inviteHash)
|
||||||
|
expect(calls.some(q => q.includes('currentWorkspace'))).toBe(true);
|
||||||
|
// Step 2: signUpInWorkspace
|
||||||
|
expect(calls.some(q => q.includes('signUpInWorkspace'))).toBe(true);
|
||||||
|
// Step 3: find member by email
|
||||||
|
expect(calls.some(q => q.includes('workspaceMembers'))).toBe(true);
|
||||||
|
// Step 4: set name
|
||||||
|
expect(calls.some(q => q.includes('updateWorkspaceMember'))).toBe(true);
|
||||||
|
// Step 5: assign role
|
||||||
|
expect(calls.some(q => q.includes('updateWorkspaceMemberRole'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Create member with SIP seat link ─────────────────────────
|
||||||
|
|
||||||
|
it('should link agent SIP seat when agentId is provided', async () => {
|
||||||
|
const input: CreateMemberInput = {
|
||||||
|
firstName: 'CC',
|
||||||
|
lastName: 'Agent',
|
||||||
|
email: 'ccagent@ramaiahcare.com',
|
||||||
|
password: 'CcRamaiah@2026',
|
||||||
|
roleId: 'role-agent-001',
|
||||||
|
agentId: 'agent-sip-001',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await service.createMember(input);
|
||||||
|
|
||||||
|
// Step 6: updateAgent (links workspaceMemberId)
|
||||||
|
const agentCall = platform.query.mock.calls.find(
|
||||||
|
c => (c[0] as string).includes('updateAgent'),
|
||||||
|
);
|
||||||
|
expect(agentCall).toBeDefined();
|
||||||
|
expect(agentCall![1]).toMatchObject({
|
||||||
|
id: 'agent-sip-001',
|
||||||
|
data: { workspaceMemberId: mockMemberId },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Validation ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('should reject missing required fields', async () => {
|
||||||
|
await expect(
|
||||||
|
service.createMember({
|
||||||
|
firstName: '',
|
||||||
|
lastName: 'Test',
|
||||||
|
email: 'test@ramaiahcare.com',
|
||||||
|
password: 'pass',
|
||||||
|
roleId: 'role-001',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject missing email', async () => {
|
||||||
|
await expect(
|
||||||
|
service.createMember({
|
||||||
|
firstName: 'Test',
|
||||||
|
lastName: 'User',
|
||||||
|
email: '',
|
||||||
|
password: 'pass',
|
||||||
|
roleId: 'role-001',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Temp password caching ────────────────────────────────────
|
||||||
|
|
||||||
|
it('should cache temp password in Redis with 24h TTL', async () => {
|
||||||
|
const input: CreateMemberInput = {
|
||||||
|
firstName: 'CC',
|
||||||
|
lastName: 'Agent',
|
||||||
|
email: 'ccagent@ramaiahcare.com',
|
||||||
|
password: 'CcRamaiah@2026',
|
||||||
|
roleId: 'role-001',
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.createMember(input);
|
||||||
|
|
||||||
|
// Redis setCache should be called with the temp password
|
||||||
|
expect(session.setCache).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('team:tempPassword:'),
|
||||||
|
'CcRamaiah@2026',
|
||||||
|
86400, // 24 hours
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Email normalization ──────────────────────────────────────
|
||||||
|
|
||||||
|
it('should lowercase email before signUp', async () => {
|
||||||
|
const input: CreateMemberInput = {
|
||||||
|
firstName: 'Test',
|
||||||
|
lastName: 'User',
|
||||||
|
email: 'CcAgent@RamaiahCare.COM',
|
||||||
|
password: 'pass',
|
||||||
|
roleId: 'role-001',
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.createMember(input);
|
||||||
|
|
||||||
|
const signUpCall = platform.query.mock.calls.find(
|
||||||
|
c => (c[0] as string).includes('signUpInWorkspace'),
|
||||||
|
);
|
||||||
|
expect(signUpCall![1]?.email).toBe('ccagent@ramaiahcare.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Workspace context caching ────────────────────────────────
|
||||||
|
|
||||||
|
it('should fetch workspace context only once for multiple creates', async () => {
|
||||||
|
let callCount = 0;
|
||||||
|
|
||||||
|
platform.query.mockImplementation((query: string) => {
|
||||||
|
if (query.includes('currentWorkspace')) {
|
||||||
|
return Promise.resolve({ currentWorkspace: mockWorkspace });
|
||||||
|
}
|
||||||
|
if (query.includes('signUpInWorkspace')) {
|
||||||
|
return Promise.resolve({ signUpInWorkspace: { workspace: { id: mockWorkspace.id } } });
|
||||||
|
}
|
||||||
|
if (query.includes('workspaceMembers')) {
|
||||||
|
callCount++;
|
||||||
|
// Return matching email based on call order
|
||||||
|
const email = callCount <= 1 ? 'a@test.com' : 'b@test.com';
|
||||||
|
return Promise.resolve({
|
||||||
|
workspaceMembers: {
|
||||||
|
edges: [{ node: { id: `member-${callCount}`, userId: 'u', userEmail: email } }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (query.includes('updateWorkspaceMember')) return Promise.resolve({ updateWorkspaceMember: { id: 'member-x' } });
|
||||||
|
if (query.includes('updateWorkspaceMemberRole')) return Promise.resolve({ updateWorkspaceMemberRole: { id: 'member-x' } });
|
||||||
|
return Promise.resolve({});
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.createMember({ firstName: 'A', lastName: 'B', email: 'a@test.com', password: 'p', roleId: 'r' });
|
||||||
|
await service.createMember({ firstName: 'C', lastName: 'D', email: 'b@test.com', password: 'p', roleId: 'r' });
|
||||||
|
|
||||||
|
const wsCalls = platform.query.mock.calls.filter(
|
||||||
|
c => (c[0] as string).includes('currentWorkspace'),
|
||||||
|
);
|
||||||
|
// Should only fetch workspace context once (cached)
|
||||||
|
expect(wsCalls.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
114
src/telephony-registration.service.ts
Normal file
114
src/telephony-registration.service.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { PlatformGraphqlService } from './platform/platform-graphql.service';
|
||||||
|
|
||||||
|
// On startup, registers this sidecar with the telephony dispatcher
|
||||||
|
// so Ozonetel events are routed to the correct sidecar by agentId.
|
||||||
|
//
|
||||||
|
// Flow:
|
||||||
|
// 1. Load agent list from platform (Agent entities in this workspace)
|
||||||
|
// 2. POST /api/supervisor/register to the dispatcher
|
||||||
|
// 3. Start heartbeat interval (every 30s)
|
||||||
|
// 4. On shutdown, DELETE /api/supervisor/register
|
||||||
|
|
||||||
|
const HEARTBEAT_INTERVAL_MS = 30_000;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TelephonyRegistrationService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(TelephonyRegistrationService.name);
|
||||||
|
private heartbeatTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private config: ConfigService,
|
||||||
|
private platform: PlatformGraphqlService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private get dispatcherUrl(): string {
|
||||||
|
return this.config.get<string>('TELEPHONY_DISPATCHER_URL') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private get sidecarUrl(): string {
|
||||||
|
return this.config.get<string>('TELEPHONY_CALLBACK_URL') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private get workspace(): string {
|
||||||
|
return process.env.PLATFORM_WORKSPACE_SUBDOMAIN ?? 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
if (!this.dispatcherUrl || !this.sidecarUrl) {
|
||||||
|
this.logger.warn('TELEPHONY_DISPATCHER_URL or TELEPHONY_CALLBACK_URL not set — skipping telephony registration');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.register();
|
||||||
|
|
||||||
|
this.heartbeatTimer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
await axios.post(`${this.dispatcherUrl}/api/supervisor/heartbeat`, {
|
||||||
|
sidecarUrl: this.sidecarUrl,
|
||||||
|
}, { timeout: 5000 });
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`Heartbeat failed: ${err.message} — attempting re-registration`);
|
||||||
|
await this.register();
|
||||||
|
}
|
||||||
|
}, HEARTBEAT_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
||||||
|
|
||||||
|
if (this.dispatcherUrl && this.sidecarUrl) {
|
||||||
|
try {
|
||||||
|
await axios.delete(`${this.dispatcherUrl}/api/supervisor/register`, {
|
||||||
|
data: { sidecarUrl: this.sidecarUrl },
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
this.logger.log('Deregistered from telephony dispatcher');
|
||||||
|
} catch {
|
||||||
|
// Best-effort — TTL will clean up anyway
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async register() {
|
||||||
|
try {
|
||||||
|
const agents = await this.loadAgentIds();
|
||||||
|
if (agents.length === 0) {
|
||||||
|
this.logger.warn('No agents found in workspace — skipping registration');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await axios.post(`${this.dispatcherUrl}/api/supervisor/register`, {
|
||||||
|
sidecarUrl: this.sidecarUrl,
|
||||||
|
workspace: this.workspace,
|
||||||
|
agents,
|
||||||
|
}, { timeout: 5000 });
|
||||||
|
|
||||||
|
this.logger.log(`Registered with telephony dispatcher: ${agents.length} agents (${agents.join(', ')})`);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`Registration failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadAgentIds(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const apiKey = this.config.get<string>('PLATFORM_API_KEY');
|
||||||
|
if (!apiKey) return [];
|
||||||
|
|
||||||
|
const data = await this.platform.queryWithAuth<any>(
|
||||||
|
`{ agents(first: 50) { edges { node { ozonetelAgentId } } } }`,
|
||||||
|
undefined,
|
||||||
|
`Bearer ${apiKey}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (data.agents?.edges ?? [])
|
||||||
|
.map((e: any) => e.node.ozonetelAgentId)
|
||||||
|
.filter((id: string) => id && id !== 'PENDING');
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`Failed to load agents from platform: ${err.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/widget/captcha.guard.ts
Normal file
46
src/widget/captcha.guard.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { CanActivate, ExecutionContext, Injectable, HttpException, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
// Cloudflare Turnstile verification endpoint
|
||||||
|
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CaptchaGuard implements CanActivate {
|
||||||
|
private readonly logger = new Logger(CaptchaGuard.name);
|
||||||
|
private readonly secretKey: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.secretKey = process.env.RECAPTCHA_SECRET_KEY ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
if (!this.secretKey) {
|
||||||
|
this.logger.warn('RECAPTCHA_SECRET_KEY not set — captcha disabled');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const token = request.body?.captchaToken;
|
||||||
|
|
||||||
|
if (!token) throw new HttpException('Captcha token required', 400);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(TURNSTILE_VERIFY_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ secret: this.secretKey, response: token }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
this.logger.warn(`Captcha failed: success=${data.success} errors=${JSON.stringify(data['error-codes'] ?? [])}`);
|
||||||
|
throw new HttpException('Captcha verification failed', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err instanceof HttpException) throw err;
|
||||||
|
this.logger.error(`Captcha verification error: ${err.message}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
226
src/widget/webhooks.controller.ts
Normal file
226
src/widget/webhooks.controller.ts
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import { Controller, Post, Get, Body, Query, Logger, HttpException } from '@nestjs/common';
|
||||||
|
import { WidgetService } from './widget.service';
|
||||||
|
import { createHmac } from 'crypto';
|
||||||
|
|
||||||
|
@Controller('api/webhook')
|
||||||
|
export class WebhooksController {
|
||||||
|
private readonly logger = new Logger(WebhooksController.name);
|
||||||
|
private readonly googleWebhookKey: string;
|
||||||
|
private readonly fbVerifyToken: string;
|
||||||
|
|
||||||
|
constructor(private readonly widget: WidgetService) {
|
||||||
|
this.googleWebhookKey = process.env.GOOGLE_WEBHOOK_KEY ?? '';
|
||||||
|
this.fbVerifyToken = process.env.FB_VERIFY_TOKEN ?? 'helix-engage-verify';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Facebook / Instagram Lead Ads ───
|
||||||
|
|
||||||
|
// Webhook verification (Meta sends GET to verify endpoint)
|
||||||
|
@Get('facebook')
|
||||||
|
verifyFacebook(
|
||||||
|
@Query('hub.mode') mode: string,
|
||||||
|
@Query('hub.verify_token') token: string,
|
||||||
|
@Query('hub.challenge') challenge: string,
|
||||||
|
) {
|
||||||
|
if (mode === 'subscribe' && token === this.fbVerifyToken) {
|
||||||
|
this.logger.log('[FB] Webhook verified');
|
||||||
|
return challenge;
|
||||||
|
}
|
||||||
|
throw new HttpException('Verification failed', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receive leads from Facebook/Instagram
|
||||||
|
@Post('facebook')
|
||||||
|
async facebookLead(@Body() body: any) {
|
||||||
|
this.logger.log(`[FB] Webhook received: ${JSON.stringify(body).substring(0, 200)}`);
|
||||||
|
|
||||||
|
if (body.object !== 'page') {
|
||||||
|
return { status: 'ignored', reason: 'not a page event' };
|
||||||
|
}
|
||||||
|
|
||||||
|
let leadsCreated = 0;
|
||||||
|
|
||||||
|
for (const entry of body.entry ?? []) {
|
||||||
|
for (const change of entry.changes ?? []) {
|
||||||
|
if (change.field !== 'leadgen') continue;
|
||||||
|
|
||||||
|
const leadData = change.value;
|
||||||
|
const leadgenId = leadData.leadgen_id;
|
||||||
|
const formId = leadData.form_id;
|
||||||
|
const pageId = leadData.page_id;
|
||||||
|
|
||||||
|
this.logger.log(`[FB] Lead received: leadgen_id=${leadgenId} form_id=${formId} page_id=${pageId}`);
|
||||||
|
|
||||||
|
// Fetch full lead data from Meta Graph API
|
||||||
|
const lead = await this.fetchFacebookLead(leadgenId);
|
||||||
|
if (!lead) {
|
||||||
|
this.logger.warn(`[FB] Could not fetch lead data for ${leadgenId}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = this.extractFbField(lead, 'full_name') ?? 'Facebook Lead';
|
||||||
|
const phone = this.extractFbField(lead, 'phone_number') ?? '';
|
||||||
|
const email = this.extractFbField(lead, 'email') ?? '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.widget.createLead({
|
||||||
|
name,
|
||||||
|
phone: phone.replace(/[^0-9+]/g, ''),
|
||||||
|
interest: `Facebook Ad (form: ${formId})`,
|
||||||
|
message: email ? `Email: ${email}` : undefined,
|
||||||
|
captchaToken: 'webhook-bypass',
|
||||||
|
});
|
||||||
|
leadsCreated++;
|
||||||
|
this.logger.log(`[FB] Lead created: ${name} (${phone})`);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[FB] Lead creation failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 'ok', leadsCreated };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchFacebookLead(leadgenId: string): Promise<any | null> {
|
||||||
|
const accessToken = process.env.FB_PAGE_ACCESS_TOKEN;
|
||||||
|
if (!accessToken) {
|
||||||
|
this.logger.warn('[FB] FB_PAGE_ACCESS_TOKEN not set — cannot fetch lead details');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://graph.facebook.com/v21.0/${leadgenId}?access_token=${accessToken}`);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return res.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractFbField(lead: any, fieldName: string): string | null {
|
||||||
|
const fields = lead.field_data ?? [];
|
||||||
|
const field = fields.find((f: any) => f.name === fieldName);
|
||||||
|
return field?.values?.[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Google Ads Lead Form ───
|
||||||
|
|
||||||
|
@Post('google')
|
||||||
|
async googleLead(@Body() body: any) {
|
||||||
|
this.logger.log(`[GOOGLE] Webhook received: ${JSON.stringify(body).substring(0, 200)}`);
|
||||||
|
|
||||||
|
// Verify webhook key if configured
|
||||||
|
if (this.googleWebhookKey && body.google_key) {
|
||||||
|
const expected = createHmac('sha256', this.googleWebhookKey)
|
||||||
|
.update(body.lead_id ?? '')
|
||||||
|
.digest('hex');
|
||||||
|
// Google sends the key directly, not as HMAC — just compare
|
||||||
|
if (body.google_key !== this.googleWebhookKey) {
|
||||||
|
this.logger.warn('[GOOGLE] Invalid webhook key');
|
||||||
|
throw new HttpException('Invalid webhook key', 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTest = body.is_test === true;
|
||||||
|
const leadId = body.lead_id;
|
||||||
|
const campaignId = body.campaign_id;
|
||||||
|
const formId = body.form_id;
|
||||||
|
|
||||||
|
// Extract user data from column data
|
||||||
|
const userData = body.user_column_data ?? [];
|
||||||
|
const name = this.extractGoogleField(userData, 'FULL_NAME')
|
||||||
|
?? this.extractGoogleField(userData, 'FIRST_NAME')
|
||||||
|
?? 'Google Ad Lead';
|
||||||
|
const phone = this.extractGoogleField(userData, 'PHONE_NUMBER') ?? '';
|
||||||
|
const email = this.extractGoogleField(userData, 'EMAIL') ?? '';
|
||||||
|
const city = this.extractGoogleField(userData, 'CITY') ?? '';
|
||||||
|
|
||||||
|
this.logger.log(`[GOOGLE] Lead: ${name} | ${phone} | campaign=${campaignId} | test=${isTest}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.widget.createLead({
|
||||||
|
name,
|
||||||
|
phone: phone.replace(/[^0-9+]/g, ''),
|
||||||
|
interest: `Google Ad${isTest ? ' (TEST)' : ''} (campaign: ${campaignId ?? 'unknown'})`,
|
||||||
|
message: [email, city].filter(Boolean).join(', ') || undefined,
|
||||||
|
captchaToken: 'webhook-bypass',
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`[GOOGLE] Lead created: ${result.leadId}${isTest ? ' (test)' : ''}`);
|
||||||
|
return { status: 'ok', leadId: result.leadId, isTest };
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[GOOGLE] Lead creation failed: ${err.message}`);
|
||||||
|
throw new HttpException('Lead creation failed', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractGoogleField(columnData: any[], fieldName: string): string | null {
|
||||||
|
const field = columnData.find((f: any) => f.column_id === fieldName);
|
||||||
|
return field?.string_value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Ozonetel WhatsApp Callback ───
|
||||||
|
// Configure in Ozonetel: Chat Callback URL → https://engage-api.srv1477139.hstgr.cloud/api/webhook/whatsapp
|
||||||
|
// Payload format will be adapted once Ozonetel confirms their schema
|
||||||
|
|
||||||
|
@Post('whatsapp')
|
||||||
|
async whatsappLead(@Body() body: any) {
|
||||||
|
this.logger.log(`[WHATSAPP] Webhook received: ${JSON.stringify(body).substring(0, 300)}`);
|
||||||
|
|
||||||
|
const phone = body.from ?? body.caller_id ?? body.phone ?? body.customerNumber ?? '';
|
||||||
|
const name = body.name ?? body.customerName ?? '';
|
||||||
|
const message = body.message ?? body.text ?? body.body ?? '';
|
||||||
|
|
||||||
|
if (!phone) {
|
||||||
|
this.logger.warn('[WHATSAPP] No phone number in payload');
|
||||||
|
return { status: 'ignored', reason: 'no phone number' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.widget.createLead({
|
||||||
|
name: name || 'WhatsApp Lead',
|
||||||
|
phone: phone.replace(/[^0-9+]/g, ''),
|
||||||
|
interest: 'WhatsApp Enquiry',
|
||||||
|
message: message || undefined,
|
||||||
|
captchaToken: 'webhook-bypass',
|
||||||
|
});
|
||||||
|
this.logger.log(`[WHATSAPP] Lead created: ${result.leadId} (${phone})`);
|
||||||
|
return { status: 'ok', leadId: result.leadId };
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[WHATSAPP] Lead creation failed: ${err.message}`);
|
||||||
|
return { status: 'error', message: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Ozonetel SMS Callback ───
|
||||||
|
// Configure in Ozonetel: SMS Callback URL → https://engage-api.srv1477139.hstgr.cloud/api/webhook/sms
|
||||||
|
|
||||||
|
@Post('sms')
|
||||||
|
async smsLead(@Body() body: any) {
|
||||||
|
this.logger.log(`[SMS] Webhook received: ${JSON.stringify(body).substring(0, 300)}`);
|
||||||
|
|
||||||
|
const phone = body.from ?? body.caller_id ?? body.phone ?? body.senderNumber ?? '';
|
||||||
|
const name = body.name ?? '';
|
||||||
|
const message = body.message ?? body.text ?? body.body ?? '';
|
||||||
|
|
||||||
|
if (!phone) {
|
||||||
|
this.logger.warn('[SMS] No phone number in payload');
|
||||||
|
return { status: 'ignored', reason: 'no phone number' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.widget.createLead({
|
||||||
|
name: name || 'SMS Lead',
|
||||||
|
phone: phone.replace(/[^0-9+]/g, ''),
|
||||||
|
interest: 'SMS Enquiry',
|
||||||
|
message: message || undefined,
|
||||||
|
captchaToken: 'webhook-bypass',
|
||||||
|
});
|
||||||
|
this.logger.log(`[SMS] Lead created: ${result.leadId} (${phone})`);
|
||||||
|
return { status: 'ok', leadId: result.leadId };
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[SMS] Lead creation failed: ${err.message}`);
|
||||||
|
return { status: 'error', message: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
420
src/widget/widget-chat.service.ts
Normal file
420
src/widget/widget-chat.service.ts
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { streamText, tool, stepCountIs } from 'ai';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { LanguageModel, ModelMessage } from 'ai';
|
||||||
|
import { createAiModel, isAiConfigured, type AiProviderOpts } from '../ai/ai-provider';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { WidgetService } from './widget.service';
|
||||||
|
import { AiConfigService } from '../config/ai-config.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WidgetChatService {
|
||||||
|
private readonly logger = new Logger(WidgetChatService.name);
|
||||||
|
private readonly apiKey: string;
|
||||||
|
private knowledgeBase: string | null = null;
|
||||||
|
private kbLoadedAt = 0;
|
||||||
|
private readonly kbTtlMs = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private config: ConfigService,
|
||||||
|
private platform: PlatformGraphqlService,
|
||||||
|
private widget: WidgetService,
|
||||||
|
private aiConfig: AiConfigService,
|
||||||
|
) {
|
||||||
|
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||||
|
if (!this.hasAiModel()) {
|
||||||
|
this.logger.warn('AI not configured — widget chat will return fallback replies');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the model on demand so admin updates to provider/model take effect
|
||||||
|
// immediately. Construction is cheap (just wraps the SDK clients).
|
||||||
|
private aiOpts(): AiProviderOpts {
|
||||||
|
const cfg = this.aiConfig.getConfig();
|
||||||
|
return {
|
||||||
|
provider: cfg.provider,
|
||||||
|
model: cfg.model,
|
||||||
|
anthropicApiKey: this.config.get<string>('ai.anthropicApiKey'),
|
||||||
|
openaiApiKey: this.config.get<string>('ai.openaiApiKey'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildAiModel(): LanguageModel | null {
|
||||||
|
return createAiModel(this.aiOpts());
|
||||||
|
}
|
||||||
|
|
||||||
|
private get auth() {
|
||||||
|
return `Bearer ${this.apiKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasAiModel(): boolean {
|
||||||
|
return isAiConfigured(this.aiOpts());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find-or-create a lead by phone. Delegates to WidgetService so there's
|
||||||
|
// a single source of truth for the dedup window + lead shape across
|
||||||
|
// chat + book + contact.
|
||||||
|
async findOrCreateLead(name: string, phone: string): Promise<string> {
|
||||||
|
return this.widget.findOrCreateLeadByPhone(name, phone, {
|
||||||
|
source: 'WEBSITE',
|
||||||
|
status: 'NEW',
|
||||||
|
interestedService: 'Website Chat',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the first name of the lead's primary contact so we can greet the
|
||||||
|
// visitor by name in the system prompt. Returns 'there' on any failure.
|
||||||
|
async getLeadFirstName(leadId: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const data = await this.platform.queryWithAuth<any>(
|
||||||
|
`query($id: UUID!) {
|
||||||
|
leads(filter: { id: { eq: $id } }, first: 1) {
|
||||||
|
edges { node { id contactName { firstName } } }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ id: leadId },
|
||||||
|
this.auth,
|
||||||
|
);
|
||||||
|
const firstName = data?.leads?.edges?.[0]?.node?.contactName?.firstName;
|
||||||
|
return (typeof firstName === 'string' && firstName.trim()) || 'there';
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to fetch lead name for ${leadId}: ${err}`);
|
||||||
|
return 'there';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append an exchange to the lead's activity log. One activity record per
|
||||||
|
// user/assistant turn. Safe to call in the background (we don't block the
|
||||||
|
// stream on this).
|
||||||
|
async logExchange(leadId: string, userText: string, aiText: string): Promise<void> {
|
||||||
|
const summary = `User: ${userText}\n\nAI: ${aiText}`.slice(0, 4000);
|
||||||
|
try {
|
||||||
|
await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($data: LeadActivityCreateInput!) {
|
||||||
|
createLeadActivity(data: $data) { id }
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
leadId,
|
||||||
|
activityType: 'NOTE_ADDED',
|
||||||
|
channel: 'SYSTEM',
|
||||||
|
summary,
|
||||||
|
occurredAt: new Date().toISOString(),
|
||||||
|
performedBy: 'Website Chat',
|
||||||
|
outcome: 'SUCCESSFUL',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
this.auth,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to log chat activity for lead ${leadId}: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a compact knowledge base of doctor/department info for the system
|
||||||
|
// prompt. Cached for 5 min to avoid a doctors query on every chat.
|
||||||
|
private async getKnowledgeBase(): Promise<string> {
|
||||||
|
const now = Date.now();
|
||||||
|
if (this.knowledgeBase && now - this.kbLoadedAt < this.kbTtlMs) {
|
||||||
|
return this.knowledgeBase;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const doctors = await this.widget.getDoctors();
|
||||||
|
const byDept = new Map<string, { name: string; visitingHours?: string; clinic?: string }[]>();
|
||||||
|
for (const d of doctors) {
|
||||||
|
const dept = (d.department ?? 'Other').replace(/_/g, ' ');
|
||||||
|
if (!byDept.has(dept)) byDept.set(dept, []);
|
||||||
|
byDept.get(dept)!.push({
|
||||||
|
name: d.name,
|
||||||
|
visitingHours: d.visitingHours,
|
||||||
|
clinic: d.clinic?.clinicName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = ['DEPARTMENTS AND DOCTORS:'];
|
||||||
|
for (const [dept, docs] of byDept) {
|
||||||
|
lines.push(`\n${dept}:`);
|
||||||
|
for (const doc of docs) {
|
||||||
|
const extras: string[] = [];
|
||||||
|
if (doc.visitingHours) extras.push(doc.visitingHours);
|
||||||
|
if (doc.clinic) extras.push(doc.clinic);
|
||||||
|
lines.push(` - ${doc.name}${extras.length ? ` (${extras.join(' • ')})` : ''}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.knowledgeBase = lines.join('\n');
|
||||||
|
this.kbLoadedAt = now;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to build widget KB: ${err}`);
|
||||||
|
this.knowledgeBase = 'DEPARTMENTS AND DOCTORS: (unavailable)';
|
||||||
|
this.kbLoadedAt = now;
|
||||||
|
}
|
||||||
|
return this.knowledgeBase;
|
||||||
|
}
|
||||||
|
|
||||||
|
async buildSystemPrompt(userName: string, selectedBranch: string | null): Promise<string> {
|
||||||
|
const init = this.widget.getInitData();
|
||||||
|
const kb = await this.getKnowledgeBase();
|
||||||
|
|
||||||
|
// Branch context flips the tool-usage rules: no branch = must call
|
||||||
|
// pick_branch first; branch set = always pass it to branch-aware
|
||||||
|
// tools. We pre-render this block since the structure is dynamic
|
||||||
|
// and the template just slots it in via {{branchContext}}.
|
||||||
|
const branchContext = selectedBranch
|
||||||
|
? [
|
||||||
|
`CURRENT BRANCH: ${selectedBranch}`,
|
||||||
|
`The visitor is interested in the ${selectedBranch} branch. You MUST pass branch="${selectedBranch}"`,
|
||||||
|
'to list_departments, show_clinic_timings, show_doctors, and show_doctor_slots every time.',
|
||||||
|
].join('\n')
|
||||||
|
: [
|
||||||
|
'BRANCH STATUS: NOT SET',
|
||||||
|
'The visitor has not picked a branch yet. Before calling list_departments, show_clinic_timings,',
|
||||||
|
'show_doctors, or show_doctor_slots, you MUST call pick_branch first so the visitor can choose.',
|
||||||
|
'Only skip this if the user asks a pure general question that does not need branch-specific data.',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
return this.aiConfig.renderPrompt('widgetChat', {
|
||||||
|
hospitalName: init.brand.name,
|
||||||
|
userName,
|
||||||
|
branchContext,
|
||||||
|
knowledgeBase: kb,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streams the assistant reply as an async iterable of UIMessageChunk-shaped
|
||||||
|
// objects. The controller writes these as SSE `data: ${json}\n\n` lines
|
||||||
|
// over the HTTP response. Tools return structured payloads the widget
|
||||||
|
// frontend renders as generative-UI cards.
|
||||||
|
async *streamReply(systemPrompt: string, messages: ModelMessage[]): AsyncGenerator<any> {
|
||||||
|
const aiModel = this.buildAiModel();
|
||||||
|
if (!aiModel) throw new Error('AI not configured');
|
||||||
|
|
||||||
|
const platform = this.platform;
|
||||||
|
const widgetSvc = this.widget;
|
||||||
|
|
||||||
|
// Branch-matching now uses the doctor's full `clinics` array
|
||||||
|
// (NormalizedDoctor) since one doctor can visit multiple
|
||||||
|
// clinics under the post-rework data model. doctorMatchesBranch
|
||||||
|
// returns true if ANY of their visit-slot clinics matches.
|
||||||
|
const matchesBranch = (d: any, branch: string | undefined): boolean => {
|
||||||
|
if (!branch) return true;
|
||||||
|
const needle = branch.toLowerCase();
|
||||||
|
const clinics: Array<{ clinicName: string }> = d.clinics ?? [];
|
||||||
|
return clinics.some((c) => c.clinicName.toLowerCase().includes(needle));
|
||||||
|
};
|
||||||
|
|
||||||
|
const tools = {
|
||||||
|
pick_branch: tool({
|
||||||
|
description:
|
||||||
|
'Show the list of hospital branches so the visitor can pick which one they are interested in. Call this BEFORE any branch-sensitive tool (list_departments, show_clinic_timings, show_doctors, show_doctor_slots) when CURRENT BRANCH is NOT SET.',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
execute: async () => {
|
||||||
|
const doctors = await widgetSvc.getDoctors();
|
||||||
|
// Branches come from the union of all doctors'
|
||||||
|
// visit-slot clinics. Each (clinic × doctor) pair
|
||||||
|
// counts once toward that branch's doctor count;
|
||||||
|
// we use a Set on doctor ids to avoid double-
|
||||||
|
// counting the same doctor against the same branch
|
||||||
|
// when they have multiple slots there.
|
||||||
|
const byBranch = new Map<
|
||||||
|
string,
|
||||||
|
{ doctorIds: Set<string>; departments: Set<string> }
|
||||||
|
>();
|
||||||
|
for (const d of doctors) {
|
||||||
|
for (const c of d.clinics ?? []) {
|
||||||
|
const name = c.clinicName?.trim();
|
||||||
|
if (!name) continue;
|
||||||
|
if (!byBranch.has(name)) {
|
||||||
|
byBranch.set(name, {
|
||||||
|
doctorIds: new Set(),
|
||||||
|
departments: new Set(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const entry = byBranch.get(name)!;
|
||||||
|
if (d.id) entry.doctorIds.add(d.id);
|
||||||
|
if (d.department) entry.departments.add(String(d.department).replace(/_/g, ' '));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
branches: Array.from(byBranch.entries())
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([name, { doctorIds, departments }]) => ({
|
||||||
|
name,
|
||||||
|
doctorCount: doctorIds.size,
|
||||||
|
departmentCount: departments.size,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
list_departments: tool({
|
||||||
|
description:
|
||||||
|
'List the departments the hospital has. Use when the visitor asks what departments or specialities are available. Pass branch if CURRENT BRANCH is set.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
branch: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Branch name to filter by. Pass the CURRENT BRANCH when set.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ branch }) => {
|
||||||
|
const doctors = await widgetSvc.getDoctors();
|
||||||
|
const filtered = doctors.filter((d: any) => matchesBranch(d, branch));
|
||||||
|
const deps = Array.from(
|
||||||
|
new Set(filtered.map((d: any) => d.department).filter(Boolean)),
|
||||||
|
) as string[];
|
||||||
|
return {
|
||||||
|
branch: branch ?? null,
|
||||||
|
departments: deps.map(d => d.replace(/_/g, ' ')),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
show_clinic_timings: tool({
|
||||||
|
description:
|
||||||
|
'Show the clinic hours / visiting times for all departments with the doctors who visit during those hours. Use when the visitor asks about clinic timings, visiting hours, when the clinic is open, or what time a department is available. Pass branch if CURRENT BRANCH is set.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
branch: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Branch name to filter by. Pass the CURRENT BRANCH when set.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ branch }) => {
|
||||||
|
const doctors = await widgetSvc.getDoctors();
|
||||||
|
const filtered = doctors.filter((d: any) => matchesBranch(d, branch));
|
||||||
|
const byDept = new Map<
|
||||||
|
string,
|
||||||
|
Array<{ name: string; hours: string; clinic: string | null }>
|
||||||
|
>();
|
||||||
|
for (const d of filtered) {
|
||||||
|
const dept = (d.department ?? 'Other').replace(/_/g, ' ');
|
||||||
|
if (!byDept.has(dept)) byDept.set(dept, []);
|
||||||
|
if (d.visitingHours) {
|
||||||
|
byDept.get(dept)!.push({
|
||||||
|
name: d.name,
|
||||||
|
hours: d.visitingHours,
|
||||||
|
clinic: d.clinic?.clinicName ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
branch: branch ?? null,
|
||||||
|
departments: Array.from(byDept.entries())
|
||||||
|
.filter(([, entries]) => entries.length > 0)
|
||||||
|
.map(([name, entries]) => ({ name, entries })),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
show_doctors: tool({
|
||||||
|
description:
|
||||||
|
'Show the list of doctors in a specific department with their visiting hours and clinic. Use when the visitor asks about doctors in a department. Pass branch if CURRENT BRANCH is set.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
department: z
|
||||||
|
.string()
|
||||||
|
.describe('Department name, e.g., "Cardiology", "ENT", "General Medicine".'),
|
||||||
|
branch: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Branch name to filter by. Pass the CURRENT BRANCH when set.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ department, branch }) => {
|
||||||
|
const doctors = await widgetSvc.getDoctors();
|
||||||
|
const deptKey = department.toLowerCase().replace(/\s+/g, '').replace(/_/g, '');
|
||||||
|
const matches = doctors
|
||||||
|
.filter((d: any) => {
|
||||||
|
const key = String(d.department ?? '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, '')
|
||||||
|
.replace(/_/g, '');
|
||||||
|
return key.includes(deptKey) && matchesBranch(d, branch);
|
||||||
|
})
|
||||||
|
.map((d: any) => ({
|
||||||
|
id: d.id,
|
||||||
|
name: d.name,
|
||||||
|
specialty: d.specialty ?? null,
|
||||||
|
visitingHours: d.visitingHours ?? null,
|
||||||
|
clinic: d.clinic?.clinicName ?? null,
|
||||||
|
}));
|
||||||
|
return { department, branch: branch ?? null, doctors: matches };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
show_doctor_slots: tool({
|
||||||
|
description:
|
||||||
|
"Show today's available appointment slots for a specific doctor. Use when the visitor wants to see when a doctor is free or wants to book with a specific doctor. The date is always today — do NOT try to specify a date. Pass branch if CURRENT BRANCH is set to disambiguate doctors with the same name across branches.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
doctorName: z
|
||||||
|
.string()
|
||||||
|
.describe('Full name of the doctor, e.g., "Dr. Lakshmi Reddy".'),
|
||||||
|
branch: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Branch name to disambiguate. Pass the CURRENT BRANCH when set.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ doctorName, branch }) => {
|
||||||
|
// Always use the server's current date. Never trust anything from
|
||||||
|
// the model here — older LLMs hallucinate their training-data
|
||||||
|
// "today" and return slots for the wrong day.
|
||||||
|
const targetDate = new Date().toISOString().slice(0, 10);
|
||||||
|
const doctors = await widgetSvc.getDoctors();
|
||||||
|
const scoped = doctors.filter((d: any) => matchesBranch(d, branch));
|
||||||
|
// Fuzzy match: lowercase + strip "Dr." prefix + collapse spaces.
|
||||||
|
const norm = (s: string) =>
|
||||||
|
s.toLowerCase().replace(/^dr\.?\s*/i, '').replace(/\s+/g, ' ').trim();
|
||||||
|
const target = norm(doctorName);
|
||||||
|
const doc =
|
||||||
|
scoped.find((d: any) => norm(d.name) === target) ??
|
||||||
|
scoped.find((d: any) => norm(d.name).includes(target)) ??
|
||||||
|
scoped.find((d: any) => target.includes(norm(d.name)));
|
||||||
|
if (!doc) {
|
||||||
|
return {
|
||||||
|
doctor: null,
|
||||||
|
date: targetDate,
|
||||||
|
slots: [],
|
||||||
|
error: `No doctor matching "${doctorName}"${branch ? ` at ${branch}` : ''} was found.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const slots = await widgetSvc.getSlots(doc.id, targetDate);
|
||||||
|
return {
|
||||||
|
doctor: {
|
||||||
|
id: doc.id,
|
||||||
|
name: doc.name,
|
||||||
|
department: doc.department ?? null,
|
||||||
|
clinic: doc.clinic?.clinicName ?? null,
|
||||||
|
},
|
||||||
|
date: targetDate,
|
||||||
|
slots,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
suggest_booking: tool({
|
||||||
|
description:
|
||||||
|
'Suggest that the visitor book an appointment. Use when the conversation is trending toward booking, the user has identified a concern, or asks "how do I book".',
|
||||||
|
inputSchema: z.object({
|
||||||
|
reason: z.string().describe('Short reason why booking is a good next step.'),
|
||||||
|
department: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Suggested department, if known.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ reason, department }) => {
|
||||||
|
return { reason, department: department ?? null };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bookings / leads are not in scope for the AI — we only wire read/
|
||||||
|
// suggest tools here. The CC agent/AP engineering team can book.
|
||||||
|
void platform;
|
||||||
|
|
||||||
|
const result = streamText({
|
||||||
|
model: aiModel,
|
||||||
|
system: systemPrompt,
|
||||||
|
messages,
|
||||||
|
tools,
|
||||||
|
stopWhen: stepCountIs(4),
|
||||||
|
});
|
||||||
|
|
||||||
|
const uiStream = result.toUIMessageStream();
|
||||||
|
for await (const chunk of uiStream) {
|
||||||
|
yield chunk;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/widget/widget-key.guard.ts
Normal file
25
src/widget/widget-key.guard.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { CanActivate, ExecutionContext, Injectable, HttpException } from '@nestjs/common';
|
||||||
|
import { WidgetKeysService } from '../config/widget-keys.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WidgetKeyGuard implements CanActivate {
|
||||||
|
constructor(private readonly keys: WidgetKeysService) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const key = request.query?.key ?? request.headers['x-widget-key'];
|
||||||
|
|
||||||
|
if (!key) throw new HttpException('Widget key required', 401);
|
||||||
|
|
||||||
|
const siteKey = await this.keys.validateKey(key);
|
||||||
|
if (!siteKey) throw new HttpException('Invalid widget key', 403);
|
||||||
|
|
||||||
|
const origin = request.headers.origin ?? request.headers.referer;
|
||||||
|
if (!this.keys.validateOrigin(siteKey, origin)) {
|
||||||
|
throw new HttpException('Origin not allowed', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.widgetSiteKey = siteKey;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
172
src/widget/widget.controller.ts
Normal file
172
src/widget/widget.controller.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { Controller, Get, Post, Delete, Body, Query, Param, Req, Res, UseGuards, Logger, HttpException } from '@nestjs/common';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import type { ModelMessage } from 'ai';
|
||||||
|
import { WidgetService } from './widget.service';
|
||||||
|
import { WidgetChatService } from './widget-chat.service';
|
||||||
|
import { WidgetKeysService } from '../config/widget-keys.service';
|
||||||
|
import { WidgetKeyGuard } from './widget-key.guard';
|
||||||
|
import { CaptchaGuard } from './captcha.guard';
|
||||||
|
import type { WidgetBookRequest, WidgetLeadRequest } from './widget.types';
|
||||||
|
|
||||||
|
type ChatStartBody = { name?: string; phone?: string };
|
||||||
|
type ChatStreamBody = { leadId?: string; messages?: ModelMessage[]; branch?: string | null };
|
||||||
|
|
||||||
|
@Controller('api/widget')
|
||||||
|
export class WidgetController {
|
||||||
|
private readonly logger = new Logger(WidgetController.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly widget: WidgetService,
|
||||||
|
private readonly chat: WidgetChatService,
|
||||||
|
private readonly keys: WidgetKeysService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('init')
|
||||||
|
@UseGuards(WidgetKeyGuard)
|
||||||
|
init() {
|
||||||
|
return this.widget.getInitData();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('doctors')
|
||||||
|
@UseGuards(WidgetKeyGuard)
|
||||||
|
async doctors() {
|
||||||
|
return this.widget.getDoctors();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('slots')
|
||||||
|
@UseGuards(WidgetKeyGuard)
|
||||||
|
async slots(@Query('doctorId') doctorId: string, @Query('date') date: string) {
|
||||||
|
if (!doctorId || !date) throw new HttpException('doctorId and date required', 400);
|
||||||
|
return this.widget.getSlots(doctorId, date);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('book')
|
||||||
|
@UseGuards(WidgetKeyGuard, CaptchaGuard)
|
||||||
|
async book(@Body() body: WidgetBookRequest) {
|
||||||
|
if (!body.patientName || !body.patientPhone || !body.doctorId || !body.scheduledAt) {
|
||||||
|
throw new HttpException('patientName, patientPhone, doctorId, and scheduledAt required', 400);
|
||||||
|
}
|
||||||
|
return this.widget.bookAppointment(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('lead')
|
||||||
|
@UseGuards(WidgetKeyGuard, CaptchaGuard)
|
||||||
|
async lead(@Body() body: WidgetLeadRequest) {
|
||||||
|
if (!body.name || !body.phone) {
|
||||||
|
throw new HttpException('name and phone required', 400);
|
||||||
|
}
|
||||||
|
return this.widget.createLead(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start (or resume) a chat session. Dedups by phone in the last 24h so a
|
||||||
|
// single visitor who books + contacts + chats doesn't create three leads.
|
||||||
|
// No CaptchaGuard: the window-level gate already verified humanity, and
|
||||||
|
// Turnstile tokens are single-use so reusing them on every endpoint breaks
|
||||||
|
// the multi-action flow.
|
||||||
|
@Post('chat-start')
|
||||||
|
@UseGuards(WidgetKeyGuard)
|
||||||
|
async chatStart(@Body() body: ChatStartBody) {
|
||||||
|
if (!body.name?.trim() || !body.phone?.trim()) {
|
||||||
|
throw new HttpException('name and phone required', 400);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const leadId = await this.chat.findOrCreateLead(body.name.trim(), body.phone.trim());
|
||||||
|
return { leadId };
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`chatStart failed: ${err?.message ?? err}`);
|
||||||
|
throw new HttpException('Failed to start chat session', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream the AI reply. Requires an active leadId from chat-start. The
|
||||||
|
// conversation is logged to leadActivity after the stream completes so the
|
||||||
|
// CC agent can review the transcript when they call the visitor back.
|
||||||
|
@Post('chat')
|
||||||
|
@UseGuards(WidgetKeyGuard)
|
||||||
|
async chat_(@Req() req: Request, @Res() res: Response) {
|
||||||
|
const body = req.body as ChatStreamBody;
|
||||||
|
const leadId = body?.leadId?.trim();
|
||||||
|
const messages = body?.messages ?? [];
|
||||||
|
const selectedBranch = body?.branch?.trim() || null;
|
||||||
|
if (!leadId) {
|
||||||
|
res.status(400).json({ error: 'leadId required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!messages.length) {
|
||||||
|
res.status(400).json({ error: 'messages required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.chat.hasAiModel()) {
|
||||||
|
res.status(503).json({ error: 'AI not configured' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the last user message up-front so we can log it after the
|
||||||
|
// stream finishes (reverse-walking messages is cheap).
|
||||||
|
const lastUser = [...messages].reverse().find(m => m.role === 'user');
|
||||||
|
const userText = typeof lastUser?.content === 'string'
|
||||||
|
? lastUser.content
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Fetch the visitor's first name from the lead so the AI can personalize.
|
||||||
|
const userName = await this.chat.getLeadFirstName(leadId);
|
||||||
|
|
||||||
|
// SSE framing — each UIMessageChunk is serialized as a `data:` event.
|
||||||
|
// See AI SDK v6 UI_MESSAGE_STREAM_HEADERS for the canonical values.
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
res.setHeader('X-Accel-Buffering', 'no');
|
||||||
|
res.setHeader('X-Vercel-Ai-Ui-Message-Stream', 'v1');
|
||||||
|
|
||||||
|
let aiText = '';
|
||||||
|
try {
|
||||||
|
const systemPrompt = await this.chat.buildSystemPrompt(userName, selectedBranch);
|
||||||
|
for await (const chunk of this.chat.streamReply(systemPrompt, messages)) {
|
||||||
|
// Track accumulated text for transcript logging.
|
||||||
|
if (chunk?.type === 'text-delta' && typeof chunk.delta === 'string') {
|
||||||
|
aiText += chunk.delta;
|
||||||
|
}
|
||||||
|
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
||||||
|
}
|
||||||
|
res.write('data: [DONE]\n\n');
|
||||||
|
res.end();
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`Chat stream failed for lead ${leadId}: ${err?.message ?? err}`);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ error: 'Chat failed' });
|
||||||
|
} else {
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'error', errorText: 'Chat failed' })}\n\n`);
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire-and-forget transcript logging. We intentionally do not await
|
||||||
|
// this so the stream response is not delayed.
|
||||||
|
if (userText && aiText) {
|
||||||
|
void this.chat.logExchange(leadId, userText, aiText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key management (admin endpoints)
|
||||||
|
@Post('keys/generate')
|
||||||
|
async generateKey(@Body() body: { hospitalName: string; allowedOrigins: string[] }) {
|
||||||
|
if (!body.hospitalName) throw new HttpException('hospitalName required', 400);
|
||||||
|
const { key, siteKey } = this.keys.generateKey(body.hospitalName, body.allowedOrigins ?? []);
|
||||||
|
await this.keys.saveKey(siteKey);
|
||||||
|
return { key, siteKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('keys')
|
||||||
|
async listKeys() {
|
||||||
|
return this.keys.listKeys();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('keys/:siteId')
|
||||||
|
async revokeKey(@Param('siteId') siteId: string) {
|
||||||
|
const revoked = await this.keys.revokeKey(siteId);
|
||||||
|
if (!revoked) throw new HttpException('Key not found', 404);
|
||||||
|
return { status: 'revoked' };
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/widget/widget.module.ts
Normal file
19
src/widget/widget.module.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
|
import { WidgetController } from './widget.controller';
|
||||||
|
import { WebhooksController } from './webhooks.controller';
|
||||||
|
import { WidgetService } from './widget.service';
|
||||||
|
import { WidgetChatService } from './widget-chat.service';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { ConfigThemeModule } from '../config/config-theme.module';
|
||||||
|
import { CallerResolutionModule } from '../caller/caller-resolution.module';
|
||||||
|
|
||||||
|
// WidgetKeysService lives in ConfigThemeModule now — injected here via the
|
||||||
|
// module's exports. This module only owns the widget-facing API endpoints
|
||||||
|
// (init / chat / book / lead) plus the NestJS guards that consume the keys.
|
||||||
|
@Module({
|
||||||
|
imports: [PlatformModule, AuthModule, ConfigThemeModule, forwardRef(() => CallerResolutionModule)],
|
||||||
|
controllers: [WidgetController, WebhooksController],
|
||||||
|
providers: [WidgetService, WidgetChatService],
|
||||||
|
})
|
||||||
|
export class WidgetModule {}
|
||||||
286
src/widget/widget.service.ts
Normal file
286
src/widget/widget.service.ts
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import type { WidgetInitResponse, WidgetBookRequest, WidgetLeadRequest } from './widget.types';
|
||||||
|
import { ThemeService } from '../config/theme.service';
|
||||||
|
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors, type NormalizedDoctor } from '../shared/doctor-utils';
|
||||||
|
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||||
|
|
||||||
|
// Dedup window: any lead created for this phone within the last 24h is
|
||||||
|
// considered the same visitor's lead — chat + book + contact by the same
|
||||||
|
// phone all roll into one record in the CRM.
|
||||||
|
const LEAD_DEDUP_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export type FindOrCreateLeadOpts = {
|
||||||
|
source?: string;
|
||||||
|
status?: string;
|
||||||
|
interestedService?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WidgetService {
|
||||||
|
private readonly logger = new Logger(WidgetService.name);
|
||||||
|
private readonly apiKey: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private platform: PlatformGraphqlService,
|
||||||
|
private theme: ThemeService,
|
||||||
|
private config: ConfigService,
|
||||||
|
private caller: CallerResolutionService,
|
||||||
|
) {
|
||||||
|
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private get auth() {
|
||||||
|
return `Bearer ${this.apiKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizePhone(raw: string): string {
|
||||||
|
return raw.replace(/[^0-9]/g, '').slice(-10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared lead dedup. Resolves via CallerResolutionService; when isNew
|
||||||
|
// (no prior Lead/Patient), we have a name here (widget form field),
|
||||||
|
// so we create both records inline. When an existing record is
|
||||||
|
// returned we update it with the latest channel + name.
|
||||||
|
async findOrCreateLeadByPhone(
|
||||||
|
name: string,
|
||||||
|
rawPhone: string,
|
||||||
|
opts: FindOrCreateLeadOpts = {},
|
||||||
|
): Promise<string> {
|
||||||
|
const phone = this.normalizePhone(rawPhone);
|
||||||
|
if (!phone) throw new Error('Invalid phone number');
|
||||||
|
|
||||||
|
const resolved = await this.caller.resolve(phone, this.auth);
|
||||||
|
const firstName = name.split(' ')[0] || name || 'Unknown';
|
||||||
|
const lastName = name.split(' ').slice(1).join(' ') || '';
|
||||||
|
|
||||||
|
if (resolved.isNew) {
|
||||||
|
// Net-new visitor — create Patient + Lead with the widget-
|
||||||
|
// collected name. Both records get the real name from the
|
||||||
|
// first moment they exist.
|
||||||
|
let patientId: string | undefined;
|
||||||
|
try {
|
||||||
|
const p = await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
name: `${firstName} ${lastName}`.trim() || 'Unknown',
|
||||||
|
fullName: { firstName, lastName },
|
||||||
|
phones: { primaryPhoneNumber: `+91${phone}` },
|
||||||
|
patientType: 'NEW',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
this.auth,
|
||||||
|
);
|
||||||
|
patientId = p?.createPatient?.id;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Widget patient create failed (${phone}): ${err}`);
|
||||||
|
}
|
||||||
|
const created = await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
contactName: { firstName, lastName },
|
||||||
|
contactPhone: { primaryPhoneNumber: `+91${phone}` },
|
||||||
|
source: opts.source ?? 'WEBSITE',
|
||||||
|
status: opts.status ?? 'NEW',
|
||||||
|
interestedService: opts.interestedService ?? 'Website Enquiry',
|
||||||
|
...(patientId ? { patientId } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
this.auth,
|
||||||
|
);
|
||||||
|
const leadId = created?.createLead?.id;
|
||||||
|
if (!leadId) throw new Error('Lead creation returned no id');
|
||||||
|
this.logger.log(`Widget lead created: ${leadId} (patient ${patientId ?? 'none'}) for ${name} (${phone})`);
|
||||||
|
return leadId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing Lead found — update with widget-supplied details.
|
||||||
|
const leadId = resolved.leadId;
|
||||||
|
try {
|
||||||
|
await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
id: leadId,
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
contactName: { firstName, lastName },
|
||||||
|
source: opts.source ?? 'WEBSITE',
|
||||||
|
status: opts.status ?? 'NEW',
|
||||||
|
interestedService: opts.interestedService ?? 'Website Enquiry',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
this.auth,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Lead update after resolve failed (lead=${leadId}): ${err}`);
|
||||||
|
}
|
||||||
|
if (resolved.patientId) {
|
||||||
|
try {
|
||||||
|
await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: resolved.patientId, data: { fullName: { firstName, lastName } } },
|
||||||
|
this.auth,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Patient rename after resolve failed (patient=${resolved.patientId}): ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.logger.log(`Widget lead updated: ${leadId} (patient ${resolved.patientId}) for ${name} (${phone})`);
|
||||||
|
return leadId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upgrade a lead's status — used when an existing lead is promoted from
|
||||||
|
// NEW/chat to APPOINTMENT_SET after the visitor books. Non-fatal on failure.
|
||||||
|
async updateLeadStatus(leadId: string, status: string, interestedService?: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($id: UUID!, $data: LeadUpdateInput!) {
|
||||||
|
updateLead(id: $id, data: $data) { id }
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
id: leadId,
|
||||||
|
data: {
|
||||||
|
status,
|
||||||
|
...(interestedService ? { interestedService } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
this.auth,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to update lead ${leadId} status → ${status}: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getInitData(): WidgetInitResponse {
|
||||||
|
const t = this.theme.getTheme();
|
||||||
|
return {
|
||||||
|
brand: { name: t.brand.hospitalName, logo: t.brand.logo },
|
||||||
|
colors: {
|
||||||
|
primary: t.colors.brand['600'] ?? 'rgb(29 78 216)',
|
||||||
|
primaryLight: t.colors.brand['50'] ?? 'rgb(219 234 254)',
|
||||||
|
text: t.colors.brand['950'] ?? 'rgb(15 23 42)',
|
||||||
|
textLight: t.colors.brand['400'] ?? 'rgb(100 116 139)',
|
||||||
|
},
|
||||||
|
captchaSiteKey: process.env.RECAPTCHA_SITE_KEY ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns NormalizedDoctor[] — the raw GraphQL fields plus three
|
||||||
|
// derived bridge fields (`clinics`, `clinic`, `visitingHours`)
|
||||||
|
// built from the visit-slots reverse relation. See
|
||||||
|
// shared/doctor-utils.ts for the rationale and the format of the
|
||||||
|
// visiting-hours summary string.
|
||||||
|
async getDoctors(): Promise<NormalizedDoctor[]> {
|
||||||
|
const data = await this.platform.queryWithAuth<any>(
|
||||||
|
`{ doctors(first: 50) { edges { node {
|
||||||
|
id name fullName { firstName lastName } department specialty
|
||||||
|
consultationFeeNew { amountMicros currencyCode }
|
||||||
|
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||||
|
} } } }`,
|
||||||
|
undefined, this.auth,
|
||||||
|
);
|
||||||
|
const raws = data.doctors.edges.map((e: any) => e.node);
|
||||||
|
return normalizeDoctors(raws);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSlots(doctorId: string, date: string): Promise<any> {
|
||||||
|
const data = await this.platform.queryWithAuth<any>(
|
||||||
|
`{ appointments(first: 50, filter: { doctorId: { eq: "${doctorId}" }, scheduledAt: { gte: "${date}T00:00:00Z", lte: "${date}T23:59:59Z" } }) { edges { node { scheduledAt } } } }`,
|
||||||
|
undefined, this.auth,
|
||||||
|
);
|
||||||
|
const booked = data.appointments.edges.map((e: any) => {
|
||||||
|
const dt = new Date(e.node.scheduledAt);
|
||||||
|
return `${dt.getHours().toString().padStart(2, '0')}:${dt.getMinutes().toString().padStart(2, '0')}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const allSlots = ['09:00', '09:30', '10:00', '10:30', '11:00', '11:30', '14:00', '14:30', '15:00', '15:30', '16:00'];
|
||||||
|
return allSlots.map(s => ({ time: s, available: !booked.includes(s) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async bookAppointment(req: WidgetBookRequest): Promise<{ appointmentId: string; reference: string }> {
|
||||||
|
const phone = this.normalizePhone(req.patientPhone);
|
||||||
|
|
||||||
|
// Find or create patient
|
||||||
|
let patientId: string | null = null;
|
||||||
|
try {
|
||||||
|
const existing = await this.platform.queryWithAuth<any>(
|
||||||
|
`{ patients(first: 1, filter: { phones: { primaryPhoneNumber: { like: "%${phone}" } } }) { edges { node { id } } } }`,
|
||||||
|
undefined, this.auth,
|
||||||
|
);
|
||||||
|
patientId = existing.patients.edges[0]?.node?.id ?? null;
|
||||||
|
} catch { /* continue */ }
|
||||||
|
|
||||||
|
if (!patientId) {
|
||||||
|
const firstName = req.patientName.split(' ')[0];
|
||||||
|
const lastName = req.patientName.split(' ').slice(1).join(' ') || '';
|
||||||
|
const created = await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||||
|
{ data: {
|
||||||
|
name: req.patientName.trim() || 'Unknown',
|
||||||
|
fullName: { firstName, lastName },
|
||||||
|
phones: { primaryPhoneNumber: `+91${phone}` },
|
||||||
|
patientType: 'NEW',
|
||||||
|
} },
|
||||||
|
this.auth,
|
||||||
|
);
|
||||||
|
patientId = created.createPatient.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create appointment
|
||||||
|
const appt = await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
|
||||||
|
{ data: {
|
||||||
|
name: `${req.patientName.trim() || 'Patient'} — ${new Date(req.scheduledAt).toISOString().slice(0, 10)}`,
|
||||||
|
scheduledAt: req.scheduledAt,
|
||||||
|
durationMin: 30,
|
||||||
|
appointmentType: 'CONSULTATION',
|
||||||
|
status: 'SCHEDULED',
|
||||||
|
doctorId: req.doctorId,
|
||||||
|
department: req.departmentId,
|
||||||
|
reasonForVisit: req.chiefComplaint ?? '',
|
||||||
|
patientId,
|
||||||
|
...(req.clinicId ? { clinicId: req.clinicId } : {}),
|
||||||
|
} },
|
||||||
|
this.auth,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find-or-create lead (dedups within 24h across chat + contact + book)
|
||||||
|
// and upgrade its status to APPOINTMENT_SET. Non-fatal on failure —
|
||||||
|
// we don't want to fail the booking if lead bookkeeping hiccups.
|
||||||
|
try {
|
||||||
|
const leadId = await this.findOrCreateLeadByPhone(req.patientName, phone, {
|
||||||
|
source: 'WEBSITE',
|
||||||
|
status: 'APPOINTMENT_SET',
|
||||||
|
interestedService: req.chiefComplaint ?? 'Appointment Booking',
|
||||||
|
});
|
||||||
|
// Idempotent upgrade: if the lead was reused from an earlier chat/
|
||||||
|
// contact, promote its status and reflect the new interest.
|
||||||
|
await this.updateLeadStatus(
|
||||||
|
leadId,
|
||||||
|
'APPOINTMENT_SET',
|
||||||
|
req.chiefComplaint ?? 'Appointment Booking',
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Widget lead upsert failed during booking: ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reference = appt.createAppointment.id.substring(0, 8).toUpperCase();
|
||||||
|
this.logger.log(`Widget booking: ${req.patientName} → ${req.doctorId} at ${req.scheduledAt} (ref: ${reference})`);
|
||||||
|
|
||||||
|
return { appointmentId: appt.createAppointment.id, reference };
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLead(req: WidgetLeadRequest): Promise<{ leadId: string }> {
|
||||||
|
const leadId = await this.findOrCreateLeadByPhone(req.name, req.phone, {
|
||||||
|
source: 'WEBSITE',
|
||||||
|
status: 'NEW',
|
||||||
|
interestedService: req.interest ?? 'Website Enquiry',
|
||||||
|
});
|
||||||
|
this.logger.log(`Widget contact: ${req.name} (${this.normalizePhone(req.phone)}) — ${req.interest ?? 'general'}`);
|
||||||
|
return { leadId };
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/widget/widget.types.ts
Normal file
39
src/widget/widget.types.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
export type WidgetSiteKey = {
|
||||||
|
siteId: string;
|
||||||
|
hospitalName: string;
|
||||||
|
allowedOrigins: string[];
|
||||||
|
active: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WidgetInitResponse = {
|
||||||
|
brand: { name: string; logo: string };
|
||||||
|
colors: { primary: string; primaryLight: string; text: string; textLight: string };
|
||||||
|
captchaSiteKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WidgetBookRequest = {
|
||||||
|
departmentId: string;
|
||||||
|
doctorId: string;
|
||||||
|
clinicId?: string;
|
||||||
|
scheduledAt: string;
|
||||||
|
patientName: string;
|
||||||
|
patientPhone: string;
|
||||||
|
age?: string;
|
||||||
|
gender?: string;
|
||||||
|
chiefComplaint?: string;
|
||||||
|
captchaToken: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WidgetLeadRequest = {
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
interest?: string;
|
||||||
|
message?: string;
|
||||||
|
captchaToken: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WidgetChatRequest = {
|
||||||
|
messages: Array<{ role: string; content: string }>;
|
||||||
|
captchaToken?: string;
|
||||||
|
};
|
||||||
@@ -1,5 +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 { 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';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
// Ozonetel sends all timestamps in IST — convert to UTC for storage
|
// Ozonetel sends all timestamps in IST — convert to UTC for storage
|
||||||
@@ -20,6 +23,9 @@ export class MissedCallWebhookController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly platform: PlatformGraphqlService,
|
private readonly platform: PlatformGraphqlService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
|
private readonly caller: CallerResolutionService,
|
||||||
|
private readonly agentLookup: AgentLookupService,
|
||||||
|
@Inject(forwardRef(() => SupervisorService)) private readonly supervisor: SupervisorService,
|
||||||
) {
|
) {
|
||||||
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||||
}
|
}
|
||||||
@@ -53,9 +59,17 @@ export class MissedCallWebhookController {
|
|||||||
return { received: true, processed: false };
|
return { received: true, processed: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip outbound calls — an unanswered outbound dial is NOT a
|
||||||
|
// "missed call" in the call-center sense. Outbound call records
|
||||||
|
// are created by the disposition flow, not the webhook.
|
||||||
|
if (type === 'Manual' || type === 'OutBound') {
|
||||||
|
this.logger.log(`Skipping outbound call webhook (type=${type}, status=${status})`);
|
||||||
|
return { received: true, processed: false, reason: 'outbound' };
|
||||||
|
}
|
||||||
|
|
||||||
// Determine call status for our platform
|
// Determine call status for our platform
|
||||||
const callStatus = status === 'Answered' ? 'COMPLETED' : 'MISSED';
|
const callStatus = status === 'Answered' ? 'COMPLETED' : 'MISSED';
|
||||||
const direction = type === 'InBound' ? 'INBOUND' : 'OUTBOUND';
|
const direction = 'INBOUND'; // only inbound reaches here now
|
||||||
|
|
||||||
// Use API key auth for server-to-server writes
|
// Use API key auth for server-to-server writes
|
||||||
const authHeader = this.apiKey ? `Bearer ${this.apiKey}` : '';
|
const authHeader = this.apiKey ? `Bearer ${this.apiKey}` : '';
|
||||||
@@ -65,7 +79,38 @@ export class MissedCallWebhookController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Create call record
|
// Step 1: Resolve caller. CallerResolutionService looks up BOTH
|
||||||
|
// leads and patients — for an existing patient with no lead yet
|
||||||
|
// it creates the lead on the fly and returns the name. This is
|
||||||
|
// the single source of truth for caller identity across webhook,
|
||||||
|
// polling, and agent-initiated paths.
|
||||||
|
let resolved: { leadId: string; leadName: string | null; patientId: string } = {
|
||||||
|
leadId: '',
|
||||||
|
leadName: null,
|
||||||
|
patientId: '',
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const r = await this.caller.resolve(callerPhone, authHeader);
|
||||||
|
const fullName = `${r.firstName} ${r.lastName}`.trim();
|
||||||
|
resolved = {
|
||||||
|
leadId: r.leadId,
|
||||||
|
// Resolver returns isNew when no Lead/Patient exists for
|
||||||
|
// this phone. We do NOT auto-create records from the
|
||||||
|
// webhook — agents don't have a name to attach, so we
|
||||||
|
// persist the phone as leadName (honest snapshot). The
|
||||||
|
// first agent action (enquiry, appointment) will create
|
||||||
|
// real Lead+Patient records and retroactive identity
|
||||||
|
// isn't a data-layer concern.
|
||||||
|
leadName: r.isNew ? `+91${callerPhone}` : (fullName || null),
|
||||||
|
patientId: r.patientId,
|
||||||
|
};
|
||||||
|
this.logger.log(`[WEBHOOK] Resolved ${callerPhone} → lead=${resolved.leadId || 'none'} name=${resolved.leadName ?? 'unresolved'} isNew=${r.isNew}`);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[WEBHOOK] Caller resolution failed for ${callerPhone}: ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Create call record with leadId + leadName baked in so
|
||||||
|
// the worklist row renders the patient name immediately.
|
||||||
const callId = await this.createCall({
|
const callId = await this.createCall({
|
||||||
callerPhone,
|
callerPhone,
|
||||||
direction,
|
direction,
|
||||||
@@ -77,25 +122,30 @@ export class MissedCallWebhookController {
|
|||||||
recordingUrl,
|
recordingUrl,
|
||||||
disposition,
|
disposition,
|
||||||
ucid,
|
ucid,
|
||||||
|
leadId: resolved.leadId || null,
|
||||||
|
leadName: resolved.leadName,
|
||||||
}, authHeader);
|
}, authHeader);
|
||||||
|
|
||||||
this.logger.log(`Created call record: ${callId} (${callStatus})`);
|
this.logger.log(`Created call record: ${callId} (${callStatus})${resolved.leadName ? ` linked to ${resolved.leadName}` : ''}`);
|
||||||
|
|
||||||
// Step 2: Find matching lead by phone number
|
// Push worklist SSE so agents see new calls instantly
|
||||||
const lead = await this.findLeadByPhone(callerPhone, authHeader);
|
// 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,
|
||||||
|
});
|
||||||
|
|
||||||
if (lead) {
|
// Step 3: Lead-side side-effects (activity log + contact stats)
|
||||||
// Step 3: Link call to lead
|
if (resolved.leadId) {
|
||||||
await this.updateCall(callId, { leadId: lead.id }, authHeader);
|
|
||||||
|
|
||||||
// Step 4: Create lead activity
|
|
||||||
const summary = callStatus === 'MISSED'
|
const summary = callStatus === 'MISSED'
|
||||||
? `Missed inbound call from ${callerPhone} (${duration}s, ${hangupBy ?? 'unknown'})`
|
? `Missed inbound call from ${callerPhone} (${duration}s, ${hangupBy ?? 'unknown'})`
|
||||||
: `Inbound call from ${callerPhone} — ${duration}s, ${disposition || 'no disposition'}`;
|
: `Inbound call from ${callerPhone} — ${duration}s, ${disposition || 'no disposition'}`;
|
||||||
|
|
||||||
await this.createLeadActivity({
|
await this.createLeadActivity({
|
||||||
leadId: lead.id,
|
leadId: resolved.leadId,
|
||||||
activityType: callStatus === 'MISSED' ? 'CALL_RECEIVED' : 'CALL_RECEIVED',
|
activityType: 'CALL_RECEIVED',
|
||||||
summary,
|
summary,
|
||||||
channel: 'PHONE',
|
channel: 'PHONE',
|
||||||
performedBy: agentName ?? 'System',
|
performedBy: agentName ?? 'System',
|
||||||
@@ -103,18 +153,16 @@ export class MissedCallWebhookController {
|
|||||||
outcome: callStatus === 'MISSED' ? 'NO_ANSWER' : 'SUCCESSFUL',
|
outcome: callStatus === 'MISSED' ? 'NO_ANSWER' : 'SUCCESSFUL',
|
||||||
}, authHeader);
|
}, authHeader);
|
||||||
|
|
||||||
// Step 5: Update lead contact timestamps
|
// Bump contact timestamps. Read current contactAttempts first
|
||||||
await this.updateLead(lead.id, {
|
// (kept local rather than extending resolve() signature).
|
||||||
|
const leadMeta = await this.findLeadByPhone(callerPhone, authHeader);
|
||||||
|
await this.updateLead(resolved.leadId, {
|
||||||
lastContacted: startTime ? new Date(startTime).toISOString() : new Date().toISOString(),
|
lastContacted: startTime ? new Date(startTime).toISOString() : new Date().toISOString(),
|
||||||
contactAttempts: (lead.contactAttempts ?? 0) + 1,
|
contactAttempts: ((leadMeta?.contactAttempts) ?? 0) + 1,
|
||||||
}, authHeader);
|
}, authHeader);
|
||||||
|
|
||||||
this.logger.log(`Linked call to lead ${lead.id} (${lead.name}), activity created`);
|
|
||||||
} else {
|
|
||||||
this.logger.log(`No matching lead for ${callerPhone} — call record created without lead link`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { received: true, processed: true, callId, leadId: lead?.id ?? null };
|
return { received: true, processed: true, callId, leadId: resolved.leadId || null };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const responseData = err?.response?.data ? JSON.stringify(err.response.data) : '';
|
const responseData = err?.response?.data ? JSON.stringify(err.response.data) : '';
|
||||||
this.logger.error(`Webhook processing failed: ${err.message} ${responseData}`);
|
this.logger.error(`Webhook processing failed: ${err.message} ${responseData}`);
|
||||||
@@ -133,6 +181,8 @@ export class MissedCallWebhookController {
|
|||||||
recordingUrl: string | null;
|
recordingUrl: string | null;
|
||||||
disposition: string | null;
|
disposition: string | null;
|
||||||
ucid: string | null;
|
ucid: string | null;
|
||||||
|
leadId?: string | null;
|
||||||
|
leadName?: string | null;
|
||||||
}, authHeader: string): Promise<string> {
|
}, authHeader: string): Promise<string> {
|
||||||
const callData: Record<string, any> = {
|
const callData: Record<string, any> = {
|
||||||
name: `${data.direction === 'INBOUND' ? 'Inbound' : 'Outbound'} — ${data.callerPhone}`,
|
name: `${data.direction === 'INBOUND' ? 'Inbound' : 'Outbound'} — ${data.callerPhone}`,
|
||||||
@@ -145,15 +195,40 @@ export class MissedCallWebhookController {
|
|||||||
durationSec: data.duration,
|
durationSec: data.duration,
|
||||||
disposition: this.mapDisposition(data.disposition),
|
disposition: this.mapDisposition(data.disposition),
|
||||||
};
|
};
|
||||||
|
// Persist UCID so the 30-min CDR enrichment cron and historical
|
||||||
|
// backfill can pair this row to a CDR record and fill in the
|
||||||
|
// authoritative agent relation.
|
||||||
|
if (data.ucid) callData.ucid = data.ucid;
|
||||||
|
if (data.leadId) callData.leadId = data.leadId;
|
||||||
|
if (data.leadName) callData.leadName = data.leadName;
|
||||||
// Set callback tracking fields for missed calls so they appear in the worklist
|
// Set callback tracking fields for missed calls so they appear in the worklist
|
||||||
if (data.callStatus === 'MISSED') {
|
if (data.callStatus === 'MISSED') {
|
||||||
callData.callbackstatus = 'PENDING_CALLBACK';
|
callData.callbackStatus = 'PENDING_CALLBACK';
|
||||||
callData.missedcallcount = 1;
|
callData.missedCallCount = 1;
|
||||||
}
|
}
|
||||||
if (data.recordingUrl) {
|
if (data.recordingUrl) {
|
||||||
callData.recording = { primaryLinkUrl: data.recordingUrl, primaryLinkLabel: 'Recording' };
|
callData.recording = { primaryLinkUrl: data.recordingUrl, primaryLinkLabel: 'Recording' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve agent relation at write-time so the supervisor dashboard
|
||||||
|
// can bucket the row immediately. Ozonetel sends transferred calls
|
||||||
|
// with a chain-style AgentName like "RamaiahAdmin -> GlobalHealthX" —
|
||||||
|
// the final handler is the last segment, so split on " -> " and
|
||||||
|
// resolve that. Try both ozonetelAgentId (lowercase unique) and
|
||||||
|
// ozonetelDisplayName (mixed-case human label) since Ozonetel mixes
|
||||||
|
// formats across webhook payloads. Leaves agentId null on miss so
|
||||||
|
// the cdr-enrichment cron can still attempt a match by UCID later.
|
||||||
|
if (data.agentName) {
|
||||||
|
const segments = data.agentName.split('->').map((s) => s.trim()).filter(Boolean);
|
||||||
|
const finalHandler = segments[segments.length - 1];
|
||||||
|
if (finalHandler) {
|
||||||
|
const uuid =
|
||||||
|
(await this.agentLookup.resolveByOzonetelId(finalHandler)) ??
|
||||||
|
(await this.agentLookup.resolveByDisplayName(finalHandler));
|
||||||
|
if (uuid) callData.agentId = uuid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await this.platform.queryWithAuth<any>(
|
const result = await this.platform.queryWithAuth<any>(
|
||||||
`mutation($data: CallCreateInput!) { createCall(data: $data) { id } }`,
|
`mutation($data: CallCreateInput!) { createCall(data: $data) { id } }`,
|
||||||
{ data: callData },
|
{ data: callData },
|
||||||
@@ -234,8 +309,9 @@ export class MissedCallWebhookController {
|
|||||||
'General Enquiry': 'INFO_PROVIDED',
|
'General Enquiry': 'INFO_PROVIDED',
|
||||||
'Appointment Booked': 'APPOINTMENT_BOOKED',
|
'Appointment Booked': 'APPOINTMENT_BOOKED',
|
||||||
'Follow Up': 'FOLLOW_UP_SCHEDULED',
|
'Follow Up': 'FOLLOW_UP_SCHEDULED',
|
||||||
'Not Interested': 'CALLBACK_REQUESTED',
|
'Not Interested': 'NOT_INTERESTED',
|
||||||
'Wrong Number': 'WRONG_NUMBER',
|
'Wrong Number': 'WRONG_NUMBER',
|
||||||
|
'No Answer': 'NO_ANSWER',
|
||||||
};
|
};
|
||||||
return map[disposition] ?? null;
|
return map[disposition] ?? null;
|
||||||
}
|
}
|
||||||
|
|||||||
212
src/worklist/missed-call-webhook.spec.ts
Normal file
212
src/worklist/missed-call-webhook.spec.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
/**
|
||||||
|
* Missed Call Webhook — unit tests
|
||||||
|
*
|
||||||
|
* QA coverage: TC-MC-01, TC-MC-02, TC-MC-03
|
||||||
|
*
|
||||||
|
* Tests verify that Ozonetel webhook payloads are correctly parsed and
|
||||||
|
* transformed into platform Call records via GraphQL mutations. The
|
||||||
|
* platform GraphQL client is mocked — no real HTTP or database calls.
|
||||||
|
*/
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { MissedCallWebhookController } from './missed-call-webhook.controller';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { AgentLookupService } from '../platform/agent-lookup.service';
|
||||||
|
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||||
|
import {
|
||||||
|
WEBHOOK_INBOUND_ANSWERED,
|
||||||
|
WEBHOOK_INBOUND_MISSED,
|
||||||
|
WEBHOOK_OUTBOUND_ANSWERED,
|
||||||
|
WEBHOOK_OUTBOUND_NO_ANSWER,
|
||||||
|
} from '../__fixtures__/ozonetel-payloads';
|
||||||
|
|
||||||
|
describe('MissedCallWebhookController', () => {
|
||||||
|
let controller: MissedCallWebhookController;
|
||||||
|
let platformGql: jest.Mocked<PlatformGraphqlService>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const mockPlatformGql = {
|
||||||
|
query: jest.fn().mockResolvedValue({}),
|
||||||
|
queryWithAuth: jest.fn().mockImplementation((query: string) => {
|
||||||
|
// createCall → return an id
|
||||||
|
if (query.includes('createCall')) return Promise.resolve({ createCall: { id: 'test-call-id' } });
|
||||||
|
// leads query → return empty (no matching lead)
|
||||||
|
if (query.includes('leads')) return Promise.resolve({ leads: { edges: [] } });
|
||||||
|
// createLeadActivity → return id
|
||||||
|
if (query.includes('createLeadActivity')) return Promise.resolve({ createLeadActivity: { id: 'test-activity-id' } });
|
||||||
|
// updateCall → return id
|
||||||
|
if (query.includes('updateCall')) return Promise.resolve({ updateCall: { id: 'test-call-id' } });
|
||||||
|
// updateLead → return id
|
||||||
|
if (query.includes('updateLead')) return Promise.resolve({ updateLead: { id: 'test-lead-id' } });
|
||||||
|
return Promise.resolve({});
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockConfig = {
|
||||||
|
get: jest.fn((key: string) => {
|
||||||
|
if (key === 'platform.apiKey') return 'test-api-key';
|
||||||
|
if (key === 'platform.graphqlUrl') return 'http://localhost:4000/graphql';
|
||||||
|
return undefined;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockCaller = {
|
||||||
|
resolve: jest.fn().mockResolvedValue({
|
||||||
|
leadId: '',
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
patientId: '',
|
||||||
|
isNew: true,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockAgentLookup = {
|
||||||
|
resolveByOzonetelId: jest.fn().mockResolvedValue(null),
|
||||||
|
resolveByDisplayName: jest.fn().mockResolvedValue(null),
|
||||||
|
};
|
||||||
|
|
||||||
|
const module = await Test.createTestingModule({
|
||||||
|
controllers: [MissedCallWebhookController],
|
||||||
|
providers: [
|
||||||
|
{ provide: PlatformGraphqlService, useValue: mockPlatformGql },
|
||||||
|
{ provide: ConfigService, useValue: mockConfig },
|
||||||
|
{ provide: CallerResolutionService, useValue: mockCaller },
|
||||||
|
{ provide: AgentLookupService, useValue: mockAgentLookup },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
controller = module.get(MissedCallWebhookController);
|
||||||
|
platformGql = module.get(PlatformGraphqlService);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── TC-MC-01: Inbound missed call logged ─────────────────────
|
||||||
|
|
||||||
|
it('TC-MC-01: should create a MISSED INBOUND call record from webhook', async () => {
|
||||||
|
const result = await controller.handleCallWebhook(WEBHOOK_INBOUND_MISSED);
|
||||||
|
|
||||||
|
expect(result).toEqual(expect.objectContaining({ received: true, processed: true }));
|
||||||
|
expect(platformGql.queryWithAuth).toHaveBeenCalled();
|
||||||
|
|
||||||
|
const mutationCall = platformGql.queryWithAuth.mock.calls.find(
|
||||||
|
(c) => typeof c[0] === 'string' && c[0].includes('createCall'),
|
||||||
|
);
|
||||||
|
expect(mutationCall).toBeDefined();
|
||||||
|
|
||||||
|
// Verify the mutation variables contain correct mapped values
|
||||||
|
const variables = mutationCall![1];
|
||||||
|
expect(variables).toMatchObject({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
callStatus: 'MISSED',
|
||||||
|
direction: 'INBOUND',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── TC-MC-02: Outbound unanswered call logged ────────────────
|
||||||
|
|
||||||
|
it('TC-MC-02: outbound unanswered call — skipped if no CallerID (by design)', async () => {
|
||||||
|
// Ozonetel outbound webhooks have empty CallerID — the controller
|
||||||
|
// skips processing when CallerID is blank. This is correct behavior:
|
||||||
|
// outbound calls are tracked via the CDR polling, not the webhook.
|
||||||
|
const result = await controller.handleCallWebhook(WEBHOOK_OUTBOUND_NO_ANSWER);
|
||||||
|
expect(result).toEqual({ received: true, processed: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Inbound answered call logged correctly ───────────────────
|
||||||
|
|
||||||
|
it('should create a COMPLETED INBOUND call record', async () => {
|
||||||
|
const result = await controller.handleCallWebhook(WEBHOOK_INBOUND_ANSWERED);
|
||||||
|
|
||||||
|
expect(result).toEqual(expect.objectContaining({ received: true, processed: true }));
|
||||||
|
|
||||||
|
const mutationCall = platformGql.queryWithAuth.mock.calls.find(
|
||||||
|
(c) => typeof c[0] === 'string' && c[0].includes('createCall'),
|
||||||
|
);
|
||||||
|
expect(mutationCall).toBeDefined();
|
||||||
|
|
||||||
|
const variables = mutationCall![1];
|
||||||
|
expect(variables).toMatchObject({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
callStatus: 'COMPLETED',
|
||||||
|
direction: 'INBOUND',
|
||||||
|
agentName: 'global',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Outbound answered call logged correctly ──────────────────
|
||||||
|
|
||||||
|
it('should skip outbound answered webhook with empty CallerID', async () => {
|
||||||
|
// Same as TC-MC-02: outbound calls have no CallerID in the webhook
|
||||||
|
const result = await controller.handleCallWebhook(WEBHOOK_OUTBOUND_ANSWERED);
|
||||||
|
expect(result).toEqual({ received: true, processed: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Duration parsing ─────────────────────────────────────────
|
||||||
|
|
||||||
|
it('should parse Ozonetel HH:MM:SS duration to seconds', async () => {
|
||||||
|
await controller.handleCallWebhook(WEBHOOK_INBOUND_ANSWERED);
|
||||||
|
|
||||||
|
const mutationCall = platformGql.queryWithAuth.mock.calls.find(
|
||||||
|
(c) => typeof c[0] === 'string' && c[0].includes('createCall'),
|
||||||
|
);
|
||||||
|
const data = mutationCall![1]?.data;
|
||||||
|
// "00:04:00" → 240 seconds
|
||||||
|
expect(data?.durationSec).toBe(240);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── CallerID stripping (+91 prefix) ──────────────────────────
|
||||||
|
|
||||||
|
it('should strip +91 prefix from CallerID', async () => {
|
||||||
|
const payload = { ...WEBHOOK_INBOUND_ANSWERED, CallerID: '+919949879837' };
|
||||||
|
await controller.handleCallWebhook(payload);
|
||||||
|
|
||||||
|
const mutationCall = platformGql.queryWithAuth.mock.calls.find(
|
||||||
|
(c) => typeof c[0] === 'string' && c[0].includes('createCall'),
|
||||||
|
);
|
||||||
|
const data = mutationCall![1]?.data;
|
||||||
|
// Controller adds +91 prefix when storing
|
||||||
|
expect(data?.callerNumber?.primaryPhoneNumber).toBe('+919949879837');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Empty CallerID skipped ───────────────────────────────────
|
||||||
|
|
||||||
|
it('should skip processing if no CallerID in webhook', async () => {
|
||||||
|
const payload = { ...WEBHOOK_INBOUND_MISSED, CallerID: '' };
|
||||||
|
const result = await controller.handleCallWebhook(payload);
|
||||||
|
|
||||||
|
expect(result).toEqual({ received: true, processed: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── IST → UTC timestamp conversion ───────────────────────────
|
||||||
|
|
||||||
|
it('should convert IST timestamps to UTC (subtract 5:30)', async () => {
|
||||||
|
await controller.handleCallWebhook(WEBHOOK_INBOUND_ANSWERED);
|
||||||
|
|
||||||
|
const mutationCall = platformGql.queryWithAuth.mock.calls.find(
|
||||||
|
(c) => typeof c[0] === 'string' && c[0].includes('createCall'),
|
||||||
|
);
|
||||||
|
const data = mutationCall![1]?.data;
|
||||||
|
|
||||||
|
// The istToUtc function subtracts 5:30 from the parsed date.
|
||||||
|
// Input: "2026-04-09 14:30:00" (treated as local by Date constructor,
|
||||||
|
// then shifted by -330 min). Verify the output is an ISO string
|
||||||
|
// that's 5:30 earlier than what Date would parse natively.
|
||||||
|
expect(data?.startedAt).toBeDefined();
|
||||||
|
expect(typeof data?.startedAt).toBe('string');
|
||||||
|
// Just verify it's a valid ISO timestamp (the exact hour depends
|
||||||
|
// on how the test machine's TZ interacts with the naive parse)
|
||||||
|
expect(new Date(data.startedAt).toISOString()).toBe(data.startedAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Handles JSON-string-wrapped body (Ozonetel quirk) ────────
|
||||||
|
|
||||||
|
it('should handle payload wrapped in a "data" JSON string', async () => {
|
||||||
|
const wrappedPayload = {
|
||||||
|
data: JSON.stringify(WEBHOOK_INBOUND_MISSED),
|
||||||
|
};
|
||||||
|
const result = await controller.handleCallWebhook(wrappedPayload);
|
||||||
|
|
||||||
|
expect(result).toEqual(expect.objectContaining({ received: true, processed: true }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,8 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
||||||
|
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||||
|
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||||
|
|
||||||
// Ozonetel sends all timestamps in IST — convert to UTC for storage
|
// Ozonetel sends all timestamps in IST — convert to UTC for storage
|
||||||
export function istToUtc(istDateStr: string | null): string | null {
|
export function istToUtc(istDateStr: string | null): string | null {
|
||||||
@@ -33,10 +35,17 @@ export class MissedQueueService implements OnModuleInit {
|
|||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly platform: PlatformGraphqlService,
|
private readonly platform: PlatformGraphqlService,
|
||||||
private readonly ozonetel: OzonetelAgentService,
|
private readonly ozonetel: OzonetelAgentService,
|
||||||
|
private readonly telephony: TelephonyConfigService,
|
||||||
|
private readonly caller: CallerResolutionService,
|
||||||
) {
|
) {
|
||||||
this.pollIntervalMs = this.config.get<number>('missedQueue.pollIntervalMs', 30000);
|
this.pollIntervalMs = this.config.get<number>('missedQueue.pollIntervalMs', 30000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read-through so admin config changes take effect without restart
|
||||||
|
private get ownCampaign(): string {
|
||||||
|
return this.telephony.getConfig().ozonetel.campaignName ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
this.logger.log(`Starting missed call ingestion polling every ${this.pollIntervalMs}ms`);
|
this.logger.log(`Starting missed call ingestion polling every ${this.pollIntervalMs}ms`);
|
||||||
setInterval(() => this.ingest().catch(err => this.logger.error('Ingestion failed', err)), this.pollIntervalMs);
|
setInterval(() => this.ingest().catch(err => this.logger.error('Ingestion failed', err)), this.pollIntervalMs);
|
||||||
@@ -61,7 +70,17 @@ export class MissedQueueService implements OnModuleInit {
|
|||||||
|
|
||||||
if (!abandonCalls?.length) return { created: 0, updated: 0 };
|
if (!abandonCalls?.length) return { created: 0, updated: 0 };
|
||||||
|
|
||||||
for (const call of abandonCalls) {
|
// Filter to this sidecar's campaign only — the Ozonetel API
|
||||||
|
// returns ALL abandoned calls across the account.
|
||||||
|
const filtered = this.ownCampaign
|
||||||
|
? abandonCalls.filter((c: any) => c.campaign === this.ownCampaign)
|
||||||
|
: abandonCalls;
|
||||||
|
|
||||||
|
if (filtered.length < abandonCalls.length) {
|
||||||
|
this.logger.log(`Filtered ${abandonCalls.length - filtered.length} calls from other campaigns (own=${this.ownCampaign})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const call of filtered) {
|
||||||
const ucid = call.monitorUCID;
|
const ucid = call.monitorUCID;
|
||||||
if (!ucid || this.processedUcids.has(ucid)) continue;
|
if (!ucid || this.processedUcids.has(ucid)) continue;
|
||||||
this.processedUcids.add(ucid);
|
this.processedUcids.add(ucid);
|
||||||
@@ -73,43 +92,46 @@ export class MissedQueueService implements OnModuleInit {
|
|||||||
const callTime = istToUtc(call.callTime) ?? new Date().toISOString();
|
const callTime = istToUtc(call.callTime) ?? new Date().toISOString();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Look up lead by phone number — strip +91 prefix for flexible matching
|
// Resolve caller via the shared service — covers the case
|
||||||
const phoneDigits = phone.replace(/^\+91/, '');
|
// where there's an existing patient but no lead yet (the
|
||||||
|
// service creates the lead on the fly and returns the name).
|
||||||
|
// Same source of truth as the webhook path.
|
||||||
let leadId: string | null = null;
|
let leadId: string | null = null;
|
||||||
let leadName: string | null = null;
|
let leadName: string | null = null;
|
||||||
try {
|
try {
|
||||||
const leadResult = await this.platform.query<any>(
|
const apiKey = this.config.get<string>('platform.apiKey') ?? '';
|
||||||
`{ leads(first: 1, filter: {
|
const auth = apiKey ? `Bearer ${apiKey}` : '';
|
||||||
contactPhone: { primaryPhoneNumber: { like: "%${phoneDigits}" } }
|
const r = await this.caller.resolve(phone, auth);
|
||||||
}) { edges { node { id contactName { firstName lastName } patientId } } } }`,
|
if (r.isNew) {
|
||||||
);
|
// No existing Lead/Patient — write phone as leadName.
|
||||||
const matchedLead = leadResult?.leads?.edges?.[0]?.node;
|
// Record creation is deferred to the first agent
|
||||||
if (matchedLead) {
|
// action (enquiry / appointment).
|
||||||
leadId = matchedLead.id;
|
leadName = phone;
|
||||||
const fn = matchedLead.contactName?.firstName ?? '';
|
} else if (r.leadId) {
|
||||||
const ln = matchedLead.contactName?.lastName ?? '';
|
leadId = r.leadId;
|
||||||
leadName = `${fn} ${ln}`.trim() || null;
|
const fullName = `${r.firstName} ${r.lastName}`.trim();
|
||||||
this.logger.log(`Matched missed call ${phone} → lead ${leadId} (${leadName})`);
|
leadName = fullName || null;
|
||||||
|
this.logger.log(`Matched missed call ${phone} → lead ${leadId} (${leadName ?? 'no name'})`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Lead lookup failed for ${phone}: ${err}`);
|
this.logger.warn(`Caller resolution failed for ${phone}: ${err}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await this.platform.query<any>(
|
const existing = await this.platform.query<any>(
|
||||||
`{ calls(first: 1, filter: {
|
`{ calls(first: 1, filter: {
|
||||||
callbackstatus: { eq: PENDING_CALLBACK },
|
callbackStatus: { eq: PENDING_CALLBACK },
|
||||||
callerNumber: { primaryPhoneNumber: { eq: "${phone}" } }
|
callerNumber: { primaryPhoneNumber: { eq: "${phone}" } }
|
||||||
}) { edges { node { id missedcallcount } } } }`,
|
}) { edges { node { id missedCallCount } } } }`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const existingNode = existing?.calls?.edges?.[0]?.node;
|
const existingNode = existing?.calls?.edges?.[0]?.node;
|
||||||
|
|
||||||
if (existingNode) {
|
if (existingNode) {
|
||||||
const newCount = (existingNode.missedcallcount || 1) + 1;
|
const newCount = (existingNode.missedCallCount || 1) + 1;
|
||||||
const updateParts = [
|
const updateParts = [
|
||||||
`missedcallcount: ${newCount}`,
|
`missedCallCount: ${newCount}`,
|
||||||
`startedAt: "${callTime}"`,
|
`startedAt: "${callTime}"`,
|
||||||
`callsourcenumber: "${did}"`,
|
`callSourceNumber: "${did}"`,
|
||||||
];
|
];
|
||||||
if (leadId) updateParts.push(`leadId: "${leadId}"`);
|
if (leadId) updateParts.push(`leadId: "${leadId}"`);
|
||||||
if (leadName) updateParts.push(`leadName: "${leadName}"`);
|
if (leadName) updateParts.push(`leadName: "${leadName}"`);
|
||||||
@@ -120,12 +142,13 @@ export class MissedQueueService implements OnModuleInit {
|
|||||||
this.logger.log(`Dedup missed call ${phone}: count now ${newCount}${leadName ? ` (${leadName})` : ''}`);
|
this.logger.log(`Dedup missed call ${phone}: count now ${newCount}${leadName ? ` (${leadName})` : ''}`);
|
||||||
} else {
|
} else {
|
||||||
const dataParts = [
|
const dataParts = [
|
||||||
|
`name: "Missed — ${phone}"`,
|
||||||
`callStatus: MISSED`,
|
`callStatus: MISSED`,
|
||||||
`direction: INBOUND`,
|
`direction: INBOUND`,
|
||||||
`callerNumber: { primaryPhoneNumber: "${phone}", primaryPhoneCallingCode: "+91" }`,
|
`callerNumber: { primaryPhoneNumber: "${phone}", primaryPhoneCallingCode: "+91" }`,
|
||||||
`callsourcenumber: "${did}"`,
|
`callSourceNumber: "${did}"`,
|
||||||
`callbackstatus: PENDING_CALLBACK`,
|
`callbackStatus: PENDING_CALLBACK`,
|
||||||
`missedcallcount: 1`,
|
`missedCallCount: 1`,
|
||||||
`startedAt: "${callTime}"`,
|
`startedAt: "${callTime}"`,
|
||||||
];
|
];
|
||||||
if (leadId) dataParts.push(`leadId: "${leadId}"`);
|
if (leadId) dataParts.push(`leadId: "${leadId}"`);
|
||||||
@@ -160,12 +183,12 @@ export class MissedQueueService implements OnModuleInit {
|
|||||||
// Find oldest unassigned PENDING_CALLBACK call (empty agentName)
|
// Find oldest unassigned PENDING_CALLBACK call (empty agentName)
|
||||||
let result = await this.platform.query<any>(
|
let result = await this.platform.query<any>(
|
||||||
`{ calls(first: 1, filter: {
|
`{ calls(first: 1, filter: {
|
||||||
callbackstatus: { eq: PENDING_CALLBACK },
|
callbackStatus: { eq: PENDING_CALLBACK },
|
||||||
agentName: { eq: "" }
|
agentName: { eq: "" }
|
||||||
}, orderBy: [{ startedAt: AscNullsLast }]) {
|
}, orderBy: [{ startedAt: AscNullsLast }]) {
|
||||||
edges { node {
|
edges { node {
|
||||||
id callerNumber { primaryPhoneNumber }
|
id callerNumber { primaryPhoneNumber }
|
||||||
startedAt callsourcenumber missedcallcount
|
startedAt callSourceNumber missedCallCount
|
||||||
} }
|
} }
|
||||||
} }`,
|
} }`,
|
||||||
);
|
);
|
||||||
@@ -176,12 +199,12 @@ export class MissedQueueService implements OnModuleInit {
|
|||||||
if (!call) {
|
if (!call) {
|
||||||
result = await this.platform.query<any>(
|
result = await this.platform.query<any>(
|
||||||
`{ calls(first: 1, filter: {
|
`{ calls(first: 1, filter: {
|
||||||
callbackstatus: { eq: PENDING_CALLBACK },
|
callbackStatus: { eq: PENDING_CALLBACK },
|
||||||
agentName: { is: NULL }
|
agentName: { is: NULL }
|
||||||
}, orderBy: [{ startedAt: AscNullsLast }]) {
|
}, orderBy: [{ startedAt: AscNullsLast }]) {
|
||||||
edges { node {
|
edges { node {
|
||||||
id callerNumber { primaryPhoneNumber }
|
id callerNumber { primaryPhoneNumber }
|
||||||
startedAt callsourcenumber missedcallcount
|
startedAt callSourceNumber missedCallCount
|
||||||
} }
|
} }
|
||||||
} }`,
|
} }`,
|
||||||
);
|
);
|
||||||
@@ -209,13 +232,13 @@ export class MissedQueueService implements OnModuleInit {
|
|||||||
throw new Error(`Invalid status: ${status}. Must be one of: ${validStatuses.join(', ')}`);
|
throw new Error(`Invalid status: ${status}. Must be one of: ${validStatuses.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataParts: string[] = [`callbackstatus: ${status}`];
|
const dataParts: string[] = [`callbackStatus: ${status}`];
|
||||||
if (status === 'CALLBACK_ATTEMPTED') {
|
if (status === 'CALLBACK_ATTEMPTED') {
|
||||||
dataParts.push(`callbackattemptedat: "${new Date().toISOString()}"`);
|
dataParts.push(`callbackAttemptedAt: "${new Date().toISOString()}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.platform.queryWithAuth<any>(
|
return this.platform.queryWithAuth<any>(
|
||||||
`mutation { updateCall(id: "${callId}", data: { ${dataParts.join(', ')} }) { id callbackstatus callbackattemptedat } }`,
|
`mutation { updateCall(id: "${callId}", data: { ${dataParts.join(', ')} }) { id callbackStatus callbackAttemptedAt } }`,
|
||||||
undefined,
|
undefined,
|
||||||
authHeader,
|
authHeader,
|
||||||
);
|
);
|
||||||
@@ -230,12 +253,12 @@ export class MissedQueueService implements OnModuleInit {
|
|||||||
const fields = `id name createdAt direction callStatus agentName
|
const fields = `id name createdAt direction callStatus agentName
|
||||||
callerNumber { primaryPhoneNumber }
|
callerNumber { primaryPhoneNumber }
|
||||||
startedAt endedAt durationSec disposition leadId
|
startedAt endedAt durationSec disposition leadId
|
||||||
callbackstatus callsourcenumber missedcallcount callbackattemptedat`;
|
callbackStatus callSourceNumber missedCallCount callbackAttemptedAt`;
|
||||||
|
|
||||||
const buildQuery = (status: string) => `{ calls(first: 50, filter: {
|
const buildQuery = (status: string) => `{ calls(first: 50, filter: {
|
||||||
agentName: { eq: "${agentName}" },
|
agentName: { eq: "${agentName}" },
|
||||||
callStatus: { eq: MISSED },
|
callStatus: { eq: MISSED },
|
||||||
callbackstatus: { eq: ${status} }
|
callbackStatus: { eq: ${status} }
|
||||||
}, orderBy: [{ startedAt: AscNullsLast }]) { edges { node { ${fields} } } } }`;
|
}, orderBy: [{ startedAt: AscNullsLast }]) { edges { node { ${fields} } } } }`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
178
src/worklist/missed-queue.spec.ts
Normal file
178
src/worklist/missed-queue.spec.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* Missed Queue Service — unit tests
|
||||||
|
*
|
||||||
|
* QA coverage: TC-MC-03 (missed calls appear in pending section)
|
||||||
|
*
|
||||||
|
* Tests the abandon call polling + ingestion logic:
|
||||||
|
* - Fetches from Ozonetel getAbandonCalls
|
||||||
|
* - Deduplicates by UCID
|
||||||
|
* - Creates Call records with callbackstatus=PENDING_CALLBACK
|
||||||
|
* - Normalizes phone numbers
|
||||||
|
* - Converts IST→UTC timestamps
|
||||||
|
*/
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { MissedQueueService, istToUtc, normalizePhone } from './missed-queue.service';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
||||||
|
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||||
|
import { ABANDON_CALL_RECORD } from '../__fixtures__/ozonetel-payloads';
|
||||||
|
|
||||||
|
describe('MissedQueueService', () => {
|
||||||
|
let service: MissedQueueService;
|
||||||
|
let platform: jest.Mocked<PlatformGraphqlService>;
|
||||||
|
let ozonetel: jest.Mocked<OzonetelAgentService>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
MissedQueueService,
|
||||||
|
{
|
||||||
|
provide: ConfigService,
|
||||||
|
useValue: {
|
||||||
|
get: jest.fn((key: string, defaultVal?: any) => {
|
||||||
|
if (key === 'missedQueue.pollIntervalMs') return 999999; // don't actually poll
|
||||||
|
if (key === 'platform.apiKey') return 'test-key';
|
||||||
|
return defaultVal;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: PlatformGraphqlService,
|
||||||
|
useValue: {
|
||||||
|
query: jest.fn().mockResolvedValue({}),
|
||||||
|
queryWithAuth: jest.fn().mockImplementation((query: string) => {
|
||||||
|
if (query.includes('createCall')) {
|
||||||
|
return Promise.resolve({ createCall: { id: 'call-missed-001' } });
|
||||||
|
}
|
||||||
|
if (query.includes('calls')) {
|
||||||
|
return Promise.resolve({ calls: { edges: [] } }); // no existing calls
|
||||||
|
}
|
||||||
|
return Promise.resolve({});
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: OzonetelAgentService,
|
||||||
|
useValue: {
|
||||||
|
getAbandonCalls: jest.fn().mockResolvedValue([ABANDON_CALL_RECORD]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: TelephonyConfigService,
|
||||||
|
useValue: {
|
||||||
|
getConfig: () => ({
|
||||||
|
ozonetel: { campaignName: 'Inbound_918041763400', agentId: '', agentPassword: '', did: '918041763400', sipId: '' },
|
||||||
|
sip: { domain: 'test', wsPort: '444' },
|
||||||
|
exotel: { apiKey: '', accountSid: '', subdomain: '' },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get(MissedQueueService);
|
||||||
|
platform = module.get(PlatformGraphqlService);
|
||||||
|
ozonetel = module.get(OzonetelAgentService);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Utility functions ────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('istToUtc', () => {
|
||||||
|
it('should subtract 5:30 from a valid IST timestamp', () => {
|
||||||
|
const result = istToUtc('2026-04-09 14:30:00');
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
const d = new Date(result!);
|
||||||
|
expect(d.toISOString()).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for null input', () => {
|
||||||
|
expect(istToUtc(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for invalid date string', () => {
|
||||||
|
expect(istToUtc('not-a-date')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizePhone', () => {
|
||||||
|
it('should strip +91 and format to +91XXXXXXXXXX', () => {
|
||||||
|
expect(normalizePhone('+919949879837')).toBe('+919949879837');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should strip 0091 prefix', () => {
|
||||||
|
expect(normalizePhone('00919949879837')).toBe('+919949879837');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should strip leading 0', () => {
|
||||||
|
expect(normalizePhone('09949879837')).toBe('+919949879837');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle raw 10-digit number', () => {
|
||||||
|
expect(normalizePhone('9949879837')).toBe('+919949879837');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should strip non-digits', () => {
|
||||||
|
expect(normalizePhone('+91-994-987-9837')).toBe('+919949879837');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Ingestion ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('ingest', () => {
|
||||||
|
it('TC-MC-03: should create MISSED call with PENDING_CALLBACK status', async () => {
|
||||||
|
const result = await service.ingest();
|
||||||
|
|
||||||
|
expect(result.created).toBeGreaterThanOrEqual(0);
|
||||||
|
|
||||||
|
// Verify createCall was called
|
||||||
|
const createCalls = platform.queryWithAuth.mock.calls.filter(
|
||||||
|
c => typeof c[0] === 'string' && c[0].includes('createCall'),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (createCalls.length > 0) {
|
||||||
|
const data = createCalls[0][1]?.data;
|
||||||
|
expect(data).toMatchObject(
|
||||||
|
expect.objectContaining({
|
||||||
|
callStatus: 'MISSED',
|
||||||
|
direction: 'INBOUND',
|
||||||
|
callbackstatus: 'PENDING_CALLBACK',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deduplicate by UCID on second ingest', async () => {
|
||||||
|
await service.ingest();
|
||||||
|
const firstCallCount = platform.queryWithAuth.mock.calls.filter(
|
||||||
|
c => typeof c[0] === 'string' && c[0].includes('createCall'),
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// Same UCID on second ingest
|
||||||
|
await service.ingest();
|
||||||
|
const secondCallCount = platform.queryWithAuth.mock.calls.filter(
|
||||||
|
c => typeof c[0] === 'string' && c[0].includes('createCall'),
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// Should not create duplicate — count stays the same
|
||||||
|
expect(secondCallCount).toBe(firstCallCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty abandon list gracefully', async () => {
|
||||||
|
ozonetel.getAbandonCalls.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
const result = await service.ingest();
|
||||||
|
|
||||||
|
expect(result.created).toBe(0);
|
||||||
|
expect(result.updated).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Ozonetel API failure gracefully', async () => {
|
||||||
|
ozonetel.getAbandonCalls.mockRejectedValueOnce(new Error('API timeout'));
|
||||||
|
|
||||||
|
const result = await service.ingest();
|
||||||
|
|
||||||
|
expect(result.created).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,9 @@ import { PlatformModule } from '../platform/platform.module';
|
|||||||
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { RulesEngineModule } from '../rules-engine/rules-engine.module';
|
import { RulesEngineModule } from '../rules-engine/rules-engine.module';
|
||||||
|
import { CallerResolutionModule } from '../caller/caller-resolution.module';
|
||||||
|
import { SupervisorModule } from '../supervisor/supervisor.module';
|
||||||
|
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||||
import { WorklistController } from './worklist.controller';
|
import { WorklistController } from './worklist.controller';
|
||||||
import { WorklistService } from './worklist.service';
|
import { WorklistService } from './worklist.service';
|
||||||
import { MissedQueueService } from './missed-queue.service';
|
import { MissedQueueService } from './missed-queue.service';
|
||||||
@@ -10,9 +13,9 @@ import { MissedCallWebhookController } from './missed-call-webhook.controller';
|
|||||||
import { KookooCallbackController } from './kookoo-callback.controller';
|
import { KookooCallbackController } from './kookoo-callback.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule), forwardRef(() => AuthModule), RulesEngineModule],
|
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule), forwardRef(() => AuthModule), RulesEngineModule, forwardRef(() => CallerResolutionModule), forwardRef(() => SupervisorModule)],
|
||||||
controllers: [WorklistController, MissedCallWebhookController, KookooCallbackController],
|
controllers: [WorklistController, MissedCallWebhookController, KookooCallbackController],
|
||||||
providers: [WorklistService, MissedQueueService],
|
providers: [WorklistService, MissedQueueService, TelephonyConfigService],
|
||||||
exports: [MissedQueueService],
|
exports: [MissedQueueService],
|
||||||
})
|
})
|
||||||
export class WorklistModule {}
|
export class WorklistModule {}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
import { WorklistConsumer } from '../rules-engine/consumers/worklist.consumer';
|
import { WorklistConsumer } from '../rules-engine/consumers/worklist.consumer';
|
||||||
|
|
||||||
@@ -16,8 +17,49 @@ export class WorklistService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly platform: PlatformGraphqlService,
|
private readonly platform: PlatformGraphqlService,
|
||||||
private readonly worklistConsumer: WorklistConsumer,
|
private readonly worklistConsumer: WorklistConsumer,
|
||||||
|
private readonly config: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private get pageSize(): number {
|
||||||
|
return this.config.get<number>('worklist.pageSize', 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get maxPages(): number {
|
||||||
|
return this.config.get<number>('worklist.maxPages', 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paginate a Relay connection query. Caller provides a function that
|
||||||
|
// builds the query for a given cursor ('' on first page). Stops when
|
||||||
|
// the platform reports no more pages OR the safety ceiling hits.
|
||||||
|
private async fetchAllPages<T>(
|
||||||
|
buildQuery: (cursorClause: string) => string,
|
||||||
|
connectionKey: string,
|
||||||
|
authHeader: string,
|
||||||
|
): Promise<T[]> {
|
||||||
|
const all: T[] = [];
|
||||||
|
let cursor = '';
|
||||||
|
for (let page = 0; page < this.maxPages; page++) {
|
||||||
|
const cursorClause = cursor ? `, after: "${cursor}"` : '';
|
||||||
|
try {
|
||||||
|
const data = await this.platform.queryWithAuth<any>(
|
||||||
|
buildQuery(cursorClause),
|
||||||
|
undefined,
|
||||||
|
authHeader,
|
||||||
|
);
|
||||||
|
const conn = data?.[connectionKey];
|
||||||
|
if (!conn) break;
|
||||||
|
all.push(...(conn.edges?.map((e: any) => e.node) ?? []));
|
||||||
|
if (!conn.pageInfo?.hasNextPage) break;
|
||||||
|
cursor = conn.pageInfo.endCursor ?? '';
|
||||||
|
if (!cursor) break;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[WORKLIST] ${connectionKey} page ${page} failed: ${err}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
async getWorklist(agentName: string, authHeader: string): Promise<WorklistResponse> {
|
async getWorklist(agentName: string, authHeader: string): Promise<WorklistResponse> {
|
||||||
const [rawMissedCalls, rawFollowUps, rawMarketingLeads] = await Promise.all([
|
const [rawMissedCalls, rawFollowUps, rawMarketingLeads] = await Promise.all([
|
||||||
this.getMissedCalls(agentName, authHeader),
|
this.getMissedCalls(agentName, authHeader),
|
||||||
@@ -49,9 +91,8 @@ export class WorklistService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getAssignedLeads(agentName: string, authHeader: string): Promise<any[]> {
|
private async getAssignedLeads(agentName: string, authHeader: string): Promise<any[]> {
|
||||||
try {
|
return this.fetchAllPages<any>(
|
||||||
const data = await this.platform.queryWithAuth<any>(
|
(cursor) => `{ leads(first: ${this.pageSize}${cursor}, filter: { assignedAgent: { eq: "${agentName}" } }, orderBy: [{ createdAt: AscNullsLast }]) { edges { node {
|
||||||
`{ leads(first: 20, filter: { assignedAgent: { eq: "${agentName}" } }, orderBy: [{ createdAt: AscNullsLast }]) { edges { node {
|
|
||||||
id createdAt
|
id createdAt
|
||||||
contactName { firstName lastName }
|
contactName { firstName lastName }
|
||||||
contactPhone { primaryPhoneNumber }
|
contactPhone { primaryPhoneNumber }
|
||||||
@@ -60,58 +101,86 @@ export class WorklistService {
|
|||||||
assignedAgent campaignId
|
assignedAgent campaignId
|
||||||
contactAttempts spamScore isSpam
|
contactAttempts spamScore isSpam
|
||||||
aiSummary aiSuggestedAction
|
aiSummary aiSuggestedAction
|
||||||
} } } }`,
|
patientId
|
||||||
undefined,
|
} } pageInfo { hasNextPage endCursor } } }`,
|
||||||
|
'leads',
|
||||||
authHeader,
|
authHeader,
|
||||||
);
|
);
|
||||||
return data.leads.edges.map((e: any) => e.node);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(`Failed to fetch assigned leads: ${err}`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getPendingFollowUps(agentName: string, authHeader: string): Promise<any[]> {
|
private async getPendingFollowUps(agentName: string, authHeader: string): Promise<any[]> {
|
||||||
try {
|
const raw = await this.fetchAllPages<any>(
|
||||||
const data = await this.platform.queryWithAuth<any>(
|
(cursor) => `{ followUps(first: ${this.pageSize}${cursor}, filter: { assignedAgent: { eq: "${agentName}" } }) { edges { node {
|
||||||
`{ followUps(first: 20, filter: { assignedAgent: { eq: "${agentName}" } }) { edges { node {
|
|
||||||
id name createdAt
|
id name createdAt
|
||||||
typeCustom status scheduledAt completedAt
|
typeCustom status scheduledAt completedAt
|
||||||
priority assignedAgent
|
priority assignedAgent
|
||||||
patientId
|
patientId
|
||||||
|
} } pageInfo { hasNextPage endCursor } } }`,
|
||||||
|
'followUps',
|
||||||
|
authHeader,
|
||||||
|
);
|
||||||
|
// Filter to PENDING/OVERDUE client-side since platform may not support in-filter on remapped fields
|
||||||
|
const followUps = raw.filter((f: any) => f.status === 'PENDING' || f.status === 'OVERDUE');
|
||||||
|
try {
|
||||||
|
|
||||||
|
// Enrich with patient name/phone so the worklist can render them.
|
||||||
|
// FollowUp stores only patientId — the name in fu.name is free-form
|
||||||
|
// and phone isn't stored at all, so one patient fetch fills both.
|
||||||
|
const patientIds: string[] = Array.from(
|
||||||
|
new Set(followUps.map((f: any) => f.patientId).filter((id: any): id is string => typeof id === 'string' && id.length > 0)),
|
||||||
|
);
|
||||||
|
if (patientIds.length > 0) {
|
||||||
|
try {
|
||||||
|
const idsGql = patientIds.map((id) => `"${id}"`).join(',');
|
||||||
|
const patientsData = await this.platform.queryWithAuth<any>(
|
||||||
|
`{ patients(first: ${patientIds.length}, filter: { id: { in: [${idsGql}] } }) { edges { node {
|
||||||
|
id fullName { firstName lastName } phones { primaryPhoneNumber }
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined,
|
undefined,
|
||||||
authHeader,
|
authHeader,
|
||||||
);
|
);
|
||||||
// Filter to PENDING/OVERDUE client-side since platform may not support in-filter on remapped fields
|
const patientMap = new Map<string, { name: string; phone: string }>();
|
||||||
return data.followUps.edges
|
for (const edge of patientsData.patients.edges) {
|
||||||
.map((e: any) => e.node)
|
const p = edge.node;
|
||||||
.filter((f: any) => f.status === 'PENDING' || f.status === 'OVERDUE');
|
const name = [p.fullName?.firstName, p.fullName?.lastName].filter(Boolean).join(' ').trim();
|
||||||
|
const phone = p.phones?.primaryPhoneNumber ?? '';
|
||||||
|
patientMap.set(p.id, { name, phone });
|
||||||
|
}
|
||||||
|
for (const fu of followUps) {
|
||||||
|
if (fu.patientId && patientMap.has(fu.patientId)) {
|
||||||
|
const p = patientMap.get(fu.patientId)!;
|
||||||
|
fu.patientName = p.name;
|
||||||
|
fu.patientPhone = p.phone;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to enrich follow-ups with patient data: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return followUps;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Failed to fetch follow-ups: ${err}`);
|
this.logger.warn(`Failed to fetch follow-ups: ${err}`);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getMissedCalls(agentName: string, authHeader: string): Promise<any[]> {
|
private async getMissedCalls(_agentName: string, authHeader: string): Promise<any[]> {
|
||||||
try {
|
// FIFO ordering (AscNullsLast) — oldest first. No agentName filter —
|
||||||
// FIFO ordering (AscNullsLast) — oldest first. No agentName filter — missed calls are a shared queue.
|
// missed calls are a shared queue. Paginated via WORKLIST_PAGE_SIZE
|
||||||
const data = await this.platform.queryWithAuth<any>(
|
// × WORKLIST_MAX_PAGES ceiling.
|
||||||
`{ calls(first: 20, filter: { callStatus: { eq: MISSED }, callbackstatus: { in: [PENDING_CALLBACK, CALLBACK_ATTEMPTED] } }, orderBy: [{ startedAt: AscNullsLast }]) { edges { node {
|
return this.fetchAllPages<any>(
|
||||||
|
(cursor) => `{ calls(first: ${this.pageSize}${cursor}, filter: { callStatus: { eq: MISSED }, callbackStatus: { in: [PENDING_CALLBACK, CALLBACK_ATTEMPTED] } }, orderBy: [{ startedAt: AscNullsLast }]) { edges { node {
|
||||||
id name createdAt
|
id name createdAt
|
||||||
direction callStatus agentName
|
direction callStatus agentName
|
||||||
callerNumber { primaryPhoneNumber }
|
callerNumber { primaryPhoneNumber }
|
||||||
startedAt endedAt durationSec
|
startedAt endedAt durationSec
|
||||||
disposition leadId
|
disposition leadId leadName
|
||||||
callbackstatus callsourcenumber missedcallcount callbackattemptedat
|
callbackStatus callSourceNumber missedCallCount callbackAttemptedAt
|
||||||
} } } }`,
|
campaign { id campaignName }
|
||||||
undefined,
|
} } pageInfo { hasNextPage endCursor } } }`,
|
||||||
|
'calls',
|
||||||
authHeader,
|
authHeader,
|
||||||
);
|
);
|
||||||
return data.calls.edges.map((e: any) => e.node);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(`Failed to fetch missed calls: ${err}`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
"exclude": ["node_modules", "test", "dist", "widget-src", "public", "data", "**/*spec.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,5 +21,6 @@
|
|||||||
"noImplicitAny": true,
|
"noImplicitAny": true,
|
||||||
"strictBindCallApply": true,
|
"strictBindCallApply": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
}
|
},
|
||||||
|
"exclude": ["widget-src", "public", "data"]
|
||||||
}
|
}
|
||||||
|
|||||||
1
widget-src/.gitignore
vendored
Normal file
1
widget-src/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules
|
||||||
2070
widget-src/package-lock.json
generated
Normal file
2070
widget-src/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
widget-src/package.json
Normal file
18
widget-src/package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "helix-engage-widget",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"preact": "^10.25.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@preact/preset-vite": "^2.9.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
75
widget-src/src/api.ts
Normal file
75
widget-src/src/api.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import type { WidgetConfig, Doctor, TimeSlot } from './types';
|
||||||
|
|
||||||
|
let baseUrl = '';
|
||||||
|
let widgetKey = '';
|
||||||
|
|
||||||
|
export const initApi = (url: string, key: string) => {
|
||||||
|
baseUrl = url;
|
||||||
|
widgetKey = key;
|
||||||
|
};
|
||||||
|
|
||||||
|
const headers = () => ({
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Widget-Key': widgetKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fetchInit = async (): Promise<WidgetConfig> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/init?key=${widgetKey}`);
|
||||||
|
if (!res.ok) throw new Error('Widget init failed');
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchDoctors = async (): Promise<Doctor[]> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/doctors?key=${widgetKey}`);
|
||||||
|
if (!res.ok) throw new Error('Failed to load doctors');
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchSlots = async (doctorId: string, date: string): Promise<TimeSlot[]> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/slots?key=${widgetKey}&doctorId=${doctorId}&date=${date}`);
|
||||||
|
if (!res.ok) throw new Error('Failed to load slots');
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const submitBooking = async (data: any): Promise<{ appointmentId: string; reference: string }> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/book?key=${widgetKey}`, {
|
||||||
|
method: 'POST', headers: headers(), body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Booking failed');
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const submitLead = async (data: any): Promise<{ leadId: string }> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/lead?key=${widgetKey}`, {
|
||||||
|
method: 'POST', headers: headers(), body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Submission failed');
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const startChatSession = async (name: string, phone: string): Promise<{ leadId: string }> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/chat-start?key=${widgetKey}`, {
|
||||||
|
method: 'POST', headers: headers(),
|
||||||
|
body: JSON.stringify({ name, phone }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Chat start failed');
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send the simplified {role, content: string}[] history to the backend.
|
||||||
|
// Backend responds with an SSE stream of UIMessageChunk events.
|
||||||
|
// branch (when set) is injected into the system prompt so the AI scopes
|
||||||
|
// tool calls to that branch.
|
||||||
|
type OutboundMessage = { role: 'user' | 'assistant'; content: string };
|
||||||
|
export const streamChat = async (
|
||||||
|
leadId: string,
|
||||||
|
messages: OutboundMessage[],
|
||||||
|
branch: string | null,
|
||||||
|
): Promise<ReadableStream<Uint8Array>> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/chat?key=${widgetKey}`, {
|
||||||
|
method: 'POST', headers: headers(),
|
||||||
|
body: JSON.stringify({ leadId, messages, branch }),
|
||||||
|
});
|
||||||
|
if (!res.ok || !res.body) throw new Error('Chat failed');
|
||||||
|
return res.body;
|
||||||
|
};
|
||||||
330
widget-src/src/booking.tsx
Normal file
330
widget-src/src/booking.tsx
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'preact/hooks';
|
||||||
|
import { fetchSlots, submitBooking } from './api';
|
||||||
|
import { departmentIcon } from './icons';
|
||||||
|
import { IconSpan } from './icon-span';
|
||||||
|
import { useWidgetStore } from './store';
|
||||||
|
import type { Doctor, TimeSlot } from './types';
|
||||||
|
|
||||||
|
type Step = 'branch' | 'department' | 'doctor' | 'datetime' | 'details' | 'success';
|
||||||
|
|
||||||
|
export const Booking = () => {
|
||||||
|
const {
|
||||||
|
visitor,
|
||||||
|
updateVisitor,
|
||||||
|
captchaToken,
|
||||||
|
bookingPrefill,
|
||||||
|
setBookingPrefill,
|
||||||
|
doctors,
|
||||||
|
doctorsLoading,
|
||||||
|
doctorsError,
|
||||||
|
branches,
|
||||||
|
selectedBranch,
|
||||||
|
setSelectedBranch,
|
||||||
|
} = useWidgetStore();
|
||||||
|
|
||||||
|
// Start on the branch step only if the visitor actually has a choice to
|
||||||
|
// make. Single-branch hospitals and chat-prefilled sessions skip it.
|
||||||
|
const needsBranchStep = branches.length > 1 && !selectedBranch;
|
||||||
|
const [step, setStep] = useState<Step>(needsBranchStep ? 'branch' : 'department');
|
||||||
|
const [selectedDept, setSelectedDept] = useState('');
|
||||||
|
const [selectedDoctor, setSelectedDoctor] = useState<Doctor | null>(null);
|
||||||
|
const [selectedDate, setSelectedDate] = useState('');
|
||||||
|
const [slots, setSlots] = useState<TimeSlot[]>([]);
|
||||||
|
const [selectedSlot, setSelectedSlot] = useState('');
|
||||||
|
const [complaint, setComplaint] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [reference, setReference] = useState('');
|
||||||
|
|
||||||
|
// Scope the roster to the selected branch up front. Every downstream
|
||||||
|
// derivation (departments list, doctor filter) works off this.
|
||||||
|
const branchDoctors = useMemo(() => {
|
||||||
|
if (!selectedBranch) return doctors;
|
||||||
|
const needle = selectedBranch.toLowerCase();
|
||||||
|
return doctors.filter(d =>
|
||||||
|
String(d.clinic?.clinicName ?? '').toLowerCase().includes(needle),
|
||||||
|
);
|
||||||
|
}, [doctors, selectedBranch]);
|
||||||
|
|
||||||
|
// Derive department list from the branch-scoped roster.
|
||||||
|
const departments = useMemo(
|
||||||
|
() => [...new Set(branchDoctors.map(d => d.department).filter(Boolean))] as string[],
|
||||||
|
[branchDoctors],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredDoctors = selectedDept
|
||||||
|
? branchDoctors.filter(d => d.department === selectedDept)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Surface a doctors-load error if the roster failed to fetch.
|
||||||
|
useEffect(() => {
|
||||||
|
if (doctorsError) setError(doctorsError);
|
||||||
|
}, [doctorsError]);
|
||||||
|
|
||||||
|
// Consume any booking prefill from chat → jump straight to the details form.
|
||||||
|
// Also locks the branch to the picked doctor's clinic so the visitor sees
|
||||||
|
// the right header badge when they land here.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bookingPrefill || doctors.length === 0) return;
|
||||||
|
const doc = doctors.find(d => d.id === bookingPrefill.doctorId);
|
||||||
|
if (!doc) return;
|
||||||
|
if (doc.clinic?.clinicName && !selectedBranch) {
|
||||||
|
setSelectedBranch(doc.clinic.clinicName);
|
||||||
|
}
|
||||||
|
setSelectedDept(doc.department);
|
||||||
|
setSelectedDoctor(doc);
|
||||||
|
setSelectedDate(bookingPrefill.date);
|
||||||
|
setSelectedSlot(bookingPrefill.time);
|
||||||
|
setStep('details');
|
||||||
|
setBookingPrefill(null);
|
||||||
|
}, [bookingPrefill, doctors]);
|
||||||
|
|
||||||
|
const handleDoctorSelect = (doc: Doctor) => {
|
||||||
|
setSelectedDoctor(doc);
|
||||||
|
setSelectedDate(new Date().toISOString().split('T')[0]);
|
||||||
|
setStep('datetime');
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedDoctor && selectedDate) {
|
||||||
|
fetchSlots(selectedDoctor.id, selectedDate).then(setSlots).catch(() => {});
|
||||||
|
}
|
||||||
|
}, [selectedDoctor, selectedDate]);
|
||||||
|
|
||||||
|
const handleBook = async () => {
|
||||||
|
if (!selectedDoctor || !selectedSlot || !visitor.name.trim() || !visitor.phone.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const scheduledAt = `${selectedDate}T${selectedSlot}:00`;
|
||||||
|
const result = await submitBooking({
|
||||||
|
departmentId: selectedDept,
|
||||||
|
doctorId: selectedDoctor.id,
|
||||||
|
scheduledAt,
|
||||||
|
patientName: visitor.name.trim(),
|
||||||
|
patientPhone: visitor.phone.trim(),
|
||||||
|
chiefComplaint: complaint,
|
||||||
|
captchaToken,
|
||||||
|
});
|
||||||
|
setReference(result.reference);
|
||||||
|
setStep('success');
|
||||||
|
} catch {
|
||||||
|
setError('Booking failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Progress bar step count is dynamic: 5 dots if we need the branch step,
|
||||||
|
// 4 otherwise. The current position is derived from the flow we're in.
|
||||||
|
const flowSteps: Step[] = needsBranchStep
|
||||||
|
? ['branch', 'department', 'doctor', 'datetime', 'details']
|
||||||
|
: ['department', 'doctor', 'datetime', 'details'];
|
||||||
|
const currentStep = flowSteps.indexOf(step);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{step !== 'success' && (
|
||||||
|
<div class="widget-steps">
|
||||||
|
{flowSteps.map((_, i) => (
|
||||||
|
<div key={i} class={`widget-step ${i < currentStep ? 'done' : i === currentStep ? 'active' : ''}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <div class="widget-error">{error}</div>}
|
||||||
|
|
||||||
|
{step === 'branch' && (
|
||||||
|
<div>
|
||||||
|
<div class="widget-section-title">Select Branch</div>
|
||||||
|
{doctorsLoading && branches.length === 0 && (
|
||||||
|
<div class="widget-section-sub">Loading…</div>
|
||||||
|
)}
|
||||||
|
{branches.map(branch => (
|
||||||
|
<button
|
||||||
|
key={branch}
|
||||||
|
class="widget-row-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedBranch(branch);
|
||||||
|
setStep('department');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconSpan class="widget-row-icon" name="hospital" size={20} />
|
||||||
|
<span class="widget-row-label">{branch}</span>
|
||||||
|
<IconSpan class="widget-row-chevron" name="arrow-right" size={14} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'department' && (
|
||||||
|
<div>
|
||||||
|
<div class="widget-section-title">
|
||||||
|
{selectedBranch && (
|
||||||
|
<>
|
||||||
|
<IconSpan class="widget-row-icon" name="hospital" size={16} />
|
||||||
|
{selectedBranch} —
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
Select Department
|
||||||
|
</div>
|
||||||
|
{doctorsLoading && departments.length === 0 && (
|
||||||
|
<div class="widget-section-sub">Loading…</div>
|
||||||
|
)}
|
||||||
|
{departments.map(dept => (
|
||||||
|
<button
|
||||||
|
key={dept}
|
||||||
|
class="widget-row-btn"
|
||||||
|
onClick={() => { setSelectedDept(dept); setStep('doctor'); }}
|
||||||
|
>
|
||||||
|
<IconSpan class="widget-row-icon" name={departmentIcon(dept)} size={20} />
|
||||||
|
<span class="widget-row-label">{dept.replace(/_/g, ' ')}</span>
|
||||||
|
<IconSpan class="widget-row-chevron" name="arrow-right" size={14} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{branches.length > 1 && (
|
||||||
|
<button
|
||||||
|
class="widget-btn widget-btn-secondary widget-btn-with-icon"
|
||||||
|
style={{ marginTop: '8px' }}
|
||||||
|
onClick={() => setStep('branch')}
|
||||||
|
>
|
||||||
|
<IconSpan name="arrow-left" size={14} />
|
||||||
|
Change branch
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'doctor' && (
|
||||||
|
<div>
|
||||||
|
<div class="widget-section-title">
|
||||||
|
<IconSpan class="widget-row-icon" name={departmentIcon(selectedDept)} size={16} />
|
||||||
|
{selectedDept.replace(/_/g, ' ')}
|
||||||
|
</div>
|
||||||
|
{filteredDoctors.map(doc => (
|
||||||
|
<button
|
||||||
|
key={doc.id}
|
||||||
|
class="widget-row-btn widget-row-btn-stack"
|
||||||
|
onClick={() => handleDoctorSelect(doc)}
|
||||||
|
>
|
||||||
|
<div class="widget-row-main">
|
||||||
|
<div class="widget-row-label">{doc.name}</div>
|
||||||
|
<div class="widget-row-sub">
|
||||||
|
{doc.visitingHours ?? ''} {doc.clinic?.clinicName ? `• ${doc.clinic.clinicName}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<IconSpan class="widget-row-chevron" name="arrow-right" size={14} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button class="widget-btn widget-btn-secondary widget-btn-with-icon" style={{ marginTop: '8px' }} onClick={() => setStep('department')}>
|
||||||
|
<IconSpan name="arrow-left" size={14} />
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'datetime' && (
|
||||||
|
<div>
|
||||||
|
<div class="widget-section-title">{selectedDoctor?.name} — Pick Date & Time</div>
|
||||||
|
<div class="widget-field">
|
||||||
|
<label class="widget-label">Date</label>
|
||||||
|
<input
|
||||||
|
class="widget-input"
|
||||||
|
type="date"
|
||||||
|
value={selectedDate}
|
||||||
|
min={new Date().toISOString().split('T')[0]}
|
||||||
|
onInput={(e: any) => { setSelectedDate(e.target.value); setSelectedSlot(''); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{slots.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label class="widget-label">Available Slots</label>
|
||||||
|
<div class="widget-slots">
|
||||||
|
{slots.map(s => (
|
||||||
|
<button
|
||||||
|
key={s.time}
|
||||||
|
class={`widget-slot ${s.time === selectedSlot ? 'selected' : ''} ${!s.available ? 'unavailable' : ''}`}
|
||||||
|
onClick={() => s.available && setSelectedSlot(s.time)}
|
||||||
|
disabled={!s.available}
|
||||||
|
>
|
||||||
|
{s.time}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div class="widget-btn-row">
|
||||||
|
<button class="widget-btn widget-btn-secondary widget-btn-with-icon" onClick={() => setStep('doctor')}>
|
||||||
|
<IconSpan name="arrow-left" size={14} />
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button class="widget-btn widget-btn-with-icon" disabled={!selectedSlot} onClick={() => setStep('details')}>
|
||||||
|
Next
|
||||||
|
<IconSpan name="arrow-right" size={14} color="#fff" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'details' && (
|
||||||
|
<div>
|
||||||
|
<div class="widget-section-title">Your Details</div>
|
||||||
|
<div class="widget-field">
|
||||||
|
<label class="widget-label">Full Name *</label>
|
||||||
|
<input
|
||||||
|
class="widget-input"
|
||||||
|
placeholder="Your name"
|
||||||
|
value={visitor.name}
|
||||||
|
onInput={(e: any) => updateVisitor({ name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="widget-field">
|
||||||
|
<label class="widget-label">Phone Number *</label>
|
||||||
|
<input
|
||||||
|
class="widget-input"
|
||||||
|
placeholder="+91 9876543210"
|
||||||
|
value={visitor.phone}
|
||||||
|
onInput={(e: any) => updateVisitor({ phone: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="widget-field">
|
||||||
|
<label class="widget-label">Chief Complaint</label>
|
||||||
|
<textarea
|
||||||
|
class="widget-input widget-textarea"
|
||||||
|
placeholder="Describe your concern..."
|
||||||
|
value={complaint}
|
||||||
|
onInput={(e: any) => setComplaint(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="widget-btn-row">
|
||||||
|
<button class="widget-btn widget-btn-secondary widget-btn-with-icon" onClick={() => setStep('datetime')}>
|
||||||
|
<IconSpan name="arrow-left" size={14} />
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="widget-btn"
|
||||||
|
disabled={!visitor.name.trim() || !visitor.phone.trim() || loading}
|
||||||
|
onClick={handleBook}
|
||||||
|
>
|
||||||
|
{loading ? 'Booking...' : 'Book Appointment'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'success' && (
|
||||||
|
<div class="widget-success">
|
||||||
|
<div class="widget-success-icon">
|
||||||
|
<IconSpan name="circle-check" size={56} color="#059669" />
|
||||||
|
</div>
|
||||||
|
<div class="widget-success-title">Appointment Booked!</div>
|
||||||
|
<div class="widget-success-text">
|
||||||
|
Reference: <strong>{reference}</strong><br />
|
||||||
|
{selectedDoctor?.name} • {selectedDate} at {selectedSlot}<br /><br />
|
||||||
|
We'll send a confirmation SMS to your phone.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
164
widget-src/src/captcha.tsx
Normal file
164
widget-src/src/captcha.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
// Cloudflare Turnstile integration.
|
||||||
|
//
|
||||||
|
// Rendering strategy: Turnstile injects its layout stylesheet into document.head,
|
||||||
|
// which does NOT cascade into our shadow DOM. When rendered inside our shadow DOM
|
||||||
|
// the iframe exists with correct attributes but paints as zero pixels because the
|
||||||
|
// wrapper Turnstile creates has no resolved styles. To work around this we mount
|
||||||
|
// Turnstile into a portal div appended to document.body (light DOM), then use
|
||||||
|
// getBoundingClientRect on the in-shadow placeholder to keep the portal visually
|
||||||
|
// overlaid on top of the captcha gate area.
|
||||||
|
|
||||||
|
type TurnstileOptions = {
|
||||||
|
sitekey: string;
|
||||||
|
callback?: (token: string) => void;
|
||||||
|
'error-callback'?: () => void;
|
||||||
|
'expired-callback'?: () => void;
|
||||||
|
theme?: 'light' | 'dark' | 'auto';
|
||||||
|
size?: 'normal' | 'compact' | 'flexible' | 'invisible';
|
||||||
|
appearance?: 'always' | 'execute' | 'interaction-only';
|
||||||
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
turnstile?: {
|
||||||
|
render: (container: HTMLElement, opts: TurnstileOptions) => string;
|
||||||
|
remove: (widgetId: string) => void;
|
||||||
|
reset: (widgetId?: string) => void;
|
||||||
|
};
|
||||||
|
__helixTurnstileLoading?: Promise<void>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
|
||||||
|
|
||||||
|
export const loadTurnstile = (): Promise<void> => {
|
||||||
|
if (typeof window === 'undefined') return Promise.resolve();
|
||||||
|
if (window.turnstile) return Promise.resolve();
|
||||||
|
if (window.__helixTurnstileLoading) return window.__helixTurnstileLoading;
|
||||||
|
|
||||||
|
window.__helixTurnstileLoading = new Promise<void>((resolve, reject) => {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = SCRIPT_URL;
|
||||||
|
script.async = true;
|
||||||
|
script.defer = true;
|
||||||
|
script.onload = () => {
|
||||||
|
const poll = () => {
|
||||||
|
if (window.turnstile) resolve();
|
||||||
|
else setTimeout(poll, 50);
|
||||||
|
};
|
||||||
|
poll();
|
||||||
|
};
|
||||||
|
script.onerror = () => reject(new Error('Turnstile failed to load'));
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
return window.__helixTurnstileLoading;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CaptchaProps = {
|
||||||
|
siteKey: string;
|
||||||
|
onToken: (token: string) => void;
|
||||||
|
onError?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Captcha = ({ siteKey, onToken, onError }: CaptchaProps) => {
|
||||||
|
const placeholderRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const portalRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const widgetIdRef = useRef<string | null>(null);
|
||||||
|
const onTokenRef = useRef(onToken);
|
||||||
|
const onErrorRef = useRef(onError);
|
||||||
|
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
|
||||||
|
|
||||||
|
onTokenRef.current = onToken;
|
||||||
|
onErrorRef.current = onError;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!siteKey || !placeholderRef.current) return;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
// Light-DOM portal so Turnstile's document.head styles actually apply.
|
||||||
|
const portal = document.createElement('div');
|
||||||
|
portal.setAttribute('data-helix-turnstile', '');
|
||||||
|
portal.style.cssText = [
|
||||||
|
'position:fixed',
|
||||||
|
'z-index:2147483647',
|
||||||
|
'width:300px',
|
||||||
|
'height:65px',
|
||||||
|
'pointer-events:auto',
|
||||||
|
].join(';');
|
||||||
|
document.body.appendChild(portal);
|
||||||
|
portalRef.current = portal;
|
||||||
|
|
||||||
|
const updatePosition = () => {
|
||||||
|
if (!placeholderRef.current || !portalRef.current) return;
|
||||||
|
const rect = placeholderRef.current.getBoundingClientRect();
|
||||||
|
portalRef.current.style.top = `${rect.top}px`;
|
||||||
|
portalRef.current.style.left = `${rect.left}px`;
|
||||||
|
};
|
||||||
|
|
||||||
|
updatePosition();
|
||||||
|
window.addEventListener('resize', updatePosition);
|
||||||
|
window.addEventListener('scroll', updatePosition, true);
|
||||||
|
|
||||||
|
// Reposition on animation frame while the gate is mounted, so the portal
|
||||||
|
// tracks the placeholder through panel open animation and any layout shifts.
|
||||||
|
let rafId = 0;
|
||||||
|
const trackLoop = () => {
|
||||||
|
updatePosition();
|
||||||
|
rafId = requestAnimationFrame(trackLoop);
|
||||||
|
};
|
||||||
|
rafId = requestAnimationFrame(trackLoop);
|
||||||
|
|
||||||
|
loadTurnstile()
|
||||||
|
.then(() => {
|
||||||
|
if (cancelled || !portalRef.current || !window.turnstile) return;
|
||||||
|
try {
|
||||||
|
widgetIdRef.current = window.turnstile.render(portalRef.current, {
|
||||||
|
sitekey: siteKey,
|
||||||
|
callback: (token) => onTokenRef.current(token),
|
||||||
|
'error-callback': () => {
|
||||||
|
setStatus('error');
|
||||||
|
onErrorRef.current?.();
|
||||||
|
},
|
||||||
|
'expired-callback': () => onTokenRef.current(''),
|
||||||
|
theme: 'light',
|
||||||
|
size: 'normal',
|
||||||
|
});
|
||||||
|
setStatus('ready');
|
||||||
|
} catch {
|
||||||
|
setStatus('error');
|
||||||
|
onErrorRef.current?.();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setStatus('error');
|
||||||
|
onErrorRef.current?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
window.removeEventListener('resize', updatePosition);
|
||||||
|
window.removeEventListener('scroll', updatePosition, true);
|
||||||
|
if (widgetIdRef.current && window.turnstile) {
|
||||||
|
try {
|
||||||
|
window.turnstile.remove(widgetIdRef.current);
|
||||||
|
} catch {
|
||||||
|
// ignore — widget may already be gone
|
||||||
|
}
|
||||||
|
widgetIdRef.current = null;
|
||||||
|
}
|
||||||
|
portal.remove();
|
||||||
|
portalRef.current = null;
|
||||||
|
};
|
||||||
|
}, [siteKey]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="widget-captcha">
|
||||||
|
<div class="widget-captcha-mount" ref={placeholderRef} />
|
||||||
|
{status === 'loading' && <div class="widget-captcha-status">Loading verification…</div>}
|
||||||
|
{status === 'error' && <div class="widget-captcha-status widget-captcha-error">Verification failed to load. Please refresh.</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user