mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
Compare commits
49 Commits
dev-kartik
...
96977e84a1
| Author | SHA1 | Date | |
|---|---|---|---|
| 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,32 +0,0 @@
|
||||
# Build outputs
|
||||
dist/
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Lock files
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# Test coverage output
|
||||
coverage/
|
||||
|
||||
# Generated type declarations
|
||||
**/*.d.ts
|
||||
*.tsbuildinfo
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# E2E test fixtures (keep unit tests)
|
||||
test/
|
||||
|
||||
# Environment secrets — never read
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -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.local
|
||||
.git
|
||||
src
|
||||
|
||||
# Local data dirs (Redis cache file, setup-state, etc.)
|
||||
data
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Server
|
||||
PORT=4100
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:8000
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
|
||||
# Fortytwo Platform
|
||||
PLATFORM_GRAPHQL_URL=http://localhost:4000/graphql
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -37,3 +37,8 @@ lerna-debug.log*
|
||||
|
||||
# Environment
|
||||
.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/
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
npx lint-staged
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
WORKDIR /app
|
||||
COPY dist ./dist
|
||||
COPY node_modules ./node_modules
|
||||
COPY package.json ./
|
||||
|
||||
# Bring across only what the runtime needs. Source, dev deps, build
|
||||
# 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
|
||||
CMD ["node", "dist/main.js"]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
NestJS sidecar that bridges Ozonetel telephony APIs with the FortyTwo platform. Handles agent auth, call control, disposition, missed call queue, worklist aggregation, AI enrichment, and live call assist.
|
||||
|
||||
**Owner: Kartik**
|
||||
**Owner: Karthik**
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -27,7 +27,7 @@ This server has **no database**. All persistent data flows to/from the FortyTwo
|
||||
| Repo | Purpose | Owner |
|
||||
|------|---------|-------|
|
||||
| `helix-engage` | React frontend | Mouli |
|
||||
| `helix-engage-server` (this) | NestJS sidecar | Kartik |
|
||||
| `helix-engage-server` (this) | NestJS sidecar | Karthik |
|
||||
| `helix-engage-app` | FortyTwo SDK app — entity schemas | Shared |
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -29,16 +29,6 @@ export default tseslint.config(
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'warn',
|
||||
'@typescript-eslint/no-unsafe-call': 'warn',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'warn',
|
||||
'@typescript-eslint/no-unsafe-return': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': 'warn',
|
||||
'@typescript-eslint/no-base-to-string': 'warn',
|
||||
'@typescript-eslint/no-misused-promises': 'warn',
|
||||
'@typescript-eslint/require-await': 'warn',
|
||||
'@typescript-eslint/no-redundant-type-constituents': 'warn',
|
||||
'no-empty': 'warn',
|
||||
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||
},
|
||||
},
|
||||
|
||||
3217
package-lock.json
generated
3217
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@@ -17,8 +17,7 @@
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"prepare": "husky"
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.58",
|
||||
@@ -32,7 +31,6 @@
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/platform-socket.io": "^11.1.17",
|
||||
"@nestjs/swagger": "^11.2.6",
|
||||
"@nestjs/websockets": "^11.1.17",
|
||||
"ai": "^6.0.116",
|
||||
"axios": "^1.13.6",
|
||||
@@ -58,9 +56,7 @@
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
"globals": "^16.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"jest": "^30.0.0",
|
||||
"lint-staged": "^16.4.0",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
@@ -72,16 +68,6 @@
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"src/**/*.ts": [
|
||||
"eslint --fix",
|
||||
"prettier --write"
|
||||
],
|
||||
"test/**/*.ts": [
|
||||
"eslint --fix",
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
@@ -99,4 +85,4 @@
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
6909
pnpm-lock.yaml
generated
6909
pnpm-lock.yaml
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 { z } from 'zod';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||
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 = {
|
||||
message: string;
|
||||
@@ -23,14 +26,20 @@ export class AiChatController {
|
||||
constructor(
|
||||
private config: ConfigService,
|
||||
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) {
|
||||
this.logger.warn('AI not configured — chat uses fallback');
|
||||
} else {
|
||||
const provider = config.get<string>('ai.provider') ?? 'openai';
|
||||
const model = config.get<string>('ai.model') ?? 'gpt-4o-mini';
|
||||
this.logger.log(`AI configured: ${provider}/${model}`);
|
||||
this.logger.log(`AI configured: ${cfg.provider}/${cfg.model}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +129,13 @@ export class AiChatController {
|
||||
undefined, auth,
|
||||
),
|
||||
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,
|
||||
),
|
||||
platformService.queryWithAuth<any>(
|
||||
@@ -137,7 +152,7 @@ export class AiChatController {
|
||||
const agentMetrics = agents
|
||||
.filter((a: any) => !agentName || a.name.toLowerCase().includes(agentName.toLowerCase()))
|
||||
.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 missed = agentCalls.filter((c: any) => c.callStatus === 'MISSED').length;
|
||||
const completed = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
|
||||
@@ -156,12 +171,12 @@ export class AiChatController {
|
||||
conversionRate: `${conversionRate}%`,
|
||||
assignedLeads: agentLeads.length,
|
||||
pendingFollowUps,
|
||||
npsScore: agent.npsscore,
|
||||
maxIdleMinutes: agent.maxidleminutes,
|
||||
minNpsThreshold: agent.minnpsthreshold,
|
||||
minConversionPercent: agent.minconversionpercent,
|
||||
belowNpsThreshold: agent.minnpsthreshold && (agent.npsscore ?? 100) < agent.minnpsthreshold,
|
||||
belowConversionThreshold: agent.minconversionpercent && conversionRate < agent.minconversionpercent,
|
||||
npsScore: agent.npsScore,
|
||||
maxIdleMinutes: agent.maxIdleMinutes,
|
||||
minNpsThreshold: agent.minNpsThreshold,
|
||||
minConversionPercent: agent.minConversion,
|
||||
belowNpsThreshold: agent.minNpsThreshold && (agent.npsScore ?? 100) < agent.minNpsThreshold,
|
||||
belowConversionThreshold: agent.minConversion && conversionRate < agent.minConversion,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -258,7 +273,7 @@ export class AiChatController {
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
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,
|
||||
);
|
||||
const breached = data.calls.edges
|
||||
@@ -344,13 +359,13 @@ export class AiChatController {
|
||||
const data = await platformService.queryWithAuth<any>(
|
||||
`{ doctors(first: 10) { edges { node {
|
||||
id fullName { firstName lastName }
|
||||
department specialty visitingHours
|
||||
department specialty
|
||||
consultationFeeNew { amountMicros currencyCode }
|
||||
clinic { clinicName }
|
||||
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||
} } } }`,
|
||||
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
|
||||
const search = doctorName.toLowerCase().replace(/^dr\.?\s*/i, '').trim();
|
||||
const searchWords = search.split(/\s+/);
|
||||
@@ -367,17 +382,18 @@ export class AiChatController {
|
||||
}),
|
||||
|
||||
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({
|
||||
patientName: z.string().describe('Full name of the patient'),
|
||||
phoneNumber: z.string().describe('Patient phone number'),
|
||||
department: z.string().describe('Department for the appointment'),
|
||||
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)'),
|
||||
reason: z.string().describe('Reason for visit'),
|
||||
}),
|
||||
execute: async ({ patientName, phoneNumber, department, doctorName, scheduledAt, reason }) => {
|
||||
this.logger.log(`[TOOL] book_appointment: ${patientName} | ${phoneNumber} | ${department} | ${doctorName} | ${scheduledAt}`);
|
||||
execute: async ({ patientName, phoneNumber, department, doctorName, clinicId, scheduledAt, reason }) => {
|
||||
this.logger.log(`[TOOL] book_appointment: ${patientName} | ${phoneNumber} | ${department} | ${doctorName} | clinic=${clinicId ?? 'none'} | ${scheduledAt}`);
|
||||
try {
|
||||
const result = await platformService.queryWithAuth<any>(
|
||||
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
|
||||
@@ -389,6 +405,7 @@ export class AiChatController {
|
||||
doctorName,
|
||||
department,
|
||||
reasonForVisit: reason,
|
||||
...(clinicId ? { clinicId } : {}),
|
||||
},
|
||||
},
|
||||
auth,
|
||||
@@ -416,16 +433,60 @@ export class AiChatController {
|
||||
this.logger.log(`[TOOL] create_lead: ${name} | ${phoneNumber} | ${interest}`);
|
||||
try {
|
||||
const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10);
|
||||
const result = await platformService.queryWithAuth<any>(
|
||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||
const resolved = await this.caller.resolve(cleanPhone, auth);
|
||||
const firstName = name.split(' ')[0];
|
||||
const lastName = name.split(' ').slice(1).join(' ') || '';
|
||||
|
||||
if (resolved.isNew) {
|
||||
// Net-new caller — create Patient + Lead with
|
||||
// the AI-collected name from the conversation.
|
||||
let patientId: string | undefined;
|
||||
try {
|
||||
const p = await platformService.queryWithAuth<any>(
|
||||
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||
{
|
||||
data: {
|
||||
fullName: { firstName, lastName },
|
||||
phones: { primaryPhoneNumber: `+91${cleanPhone}` },
|
||||
patientType: 'NEW',
|
||||
},
|
||||
},
|
||||
auth,
|
||||
);
|
||||
patientId = p?.createPatient?.id;
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`[TOOL] create_lead patient create failed: ${err.message}`);
|
||||
}
|
||||
const created = await platformService.queryWithAuth<any>(
|
||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||
{
|
||||
data: {
|
||||
name: `AI Enquiry — ${name}`,
|
||||
contactName: { firstName, lastName },
|
||||
contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` },
|
||||
source: 'PHONE',
|
||||
status: 'NEW',
|
||||
interestedService: interest,
|
||||
...(patientId ? { patientId } : {}),
|
||||
},
|
||||
},
|
||||
auth,
|
||||
);
|
||||
const id = created?.createLead?.id;
|
||||
if (id) {
|
||||
return { created: true, leadId: id, message: `Lead created for ${name}. Our team will follow up on ${phoneNumber}.` };
|
||||
}
|
||||
return { created: false, message: 'Lead creation failed.' };
|
||||
}
|
||||
|
||||
// Existing record — update with AI-collected name.
|
||||
await platformService.queryWithAuth<any>(
|
||||
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
||||
{
|
||||
id: resolved.leadId,
|
||||
data: {
|
||||
name: `AI Enquiry — ${name}`,
|
||||
contactName: {
|
||||
firstName: name.split(' ')[0],
|
||||
lastName: name.split(' ').slice(1).join(' ') || '',
|
||||
},
|
||||
contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` },
|
||||
contactName: { firstName, lastName },
|
||||
source: 'PHONE',
|
||||
status: 'NEW',
|
||||
interestedService: interest,
|
||||
@@ -433,11 +494,14 @@ export class AiChatController {
|
||||
},
|
||||
auth,
|
||||
);
|
||||
const id = result?.createLead?.id;
|
||||
if (id) {
|
||||
return { created: true, leadId: id, message: `Lead created for ${name}. Our team will follow up on ${phoneNumber}.` };
|
||||
if (resolved.patientId) {
|
||||
await platformService.queryWithAuth<any>(
|
||||
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
|
||||
{ id: resolved.patientId, data: { fullName: { firstName, lastName } } },
|
||||
auth,
|
||||
).catch(() => {});
|
||||
}
|
||||
return { created: false, message: 'Lead creation failed.' };
|
||||
return { created: true, leadId: resolved.leadId, message: `Lead updated for ${name}. Our team will follow up on ${phoneNumber}.` };
|
||||
} catch (err: any) {
|
||||
this.logger.error(`[TOOL] create_lead failed: ${err.message}`);
|
||||
return { created: false, message: `Failed: ${err.message}` };
|
||||
@@ -503,16 +567,23 @@ export class AiChatController {
|
||||
`{ clinics(first: 20) { edges { node {
|
||||
id name clinicName
|
||||
addressCustom { addressStreet1 addressCity addressState addressPostcode }
|
||||
weekdayHours saturdayHours sundayHours
|
||||
openMonday openTuesday openWednesday openThursday openFriday openSaturday openSunday
|
||||
opensAt closesAt
|
||||
status walkInAllowed onlineBooking
|
||||
cancellationWindowHours arriveEarlyMin requiredDocuments
|
||||
cancellationWindowHours arriveEarlyMin
|
||||
acceptsCash acceptsCard acceptsUpi
|
||||
requiredDocuments { edges { node { documentType notes } } }
|
||||
} } } }`,
|
||||
undefined, auth,
|
||||
);
|
||||
const clinics = clinicData.clinics.edges.map((e: any) => e.node);
|
||||
if (clinics.length) {
|
||||
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) {
|
||||
const name = c.clinicName ?? c.name;
|
||||
const addr = c.addressCustom
|
||||
@@ -520,9 +591,15 @@ export class AiChatController {
|
||||
: '';
|
||||
sections.push(`### ${name}`);
|
||||
if (addr) sections.push(` Address: ${addr}`);
|
||||
if (c.weekdayHours) sections.push(` Mon–Fri: ${c.weekdayHours}`);
|
||||
if (c.saturdayHours) sections.push(` Saturday: ${c.saturdayHours}`);
|
||||
sections.push(` Sunday: ${c.sundayHours ?? 'Closed'}`);
|
||||
const openDays = dayFlags.filter(([, flag]) => c[flag]).map(([label]) => label);
|
||||
if (openDays.length) {
|
||||
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`);
|
||||
}
|
||||
|
||||
@@ -530,7 +607,8 @@ export class AiChatController {
|
||||
const rules: string[] = [];
|
||||
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.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.onlineBooking) rules.push('Online booking available');
|
||||
if (rules.length) {
|
||||
@@ -556,25 +634,28 @@ export class AiChatController {
|
||||
try {
|
||||
const docData = await this.platform.queryWithAuth<any>(
|
||||
`{ doctors(first: 20) { edges { node {
|
||||
fullName { firstName lastName } department specialty visitingHours
|
||||
id fullName { firstName lastName } department specialty
|
||||
consultationFeeNew { amountMicros currencyCode }
|
||||
clinic { clinicName }
|
||||
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||
} } } }`,
|
||||
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) {
|
||||
sections.push('\n## DOCTORS');
|
||||
for (const d of doctors) {
|
||||
const name = `Dr. ${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''}`.trim();
|
||||
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(` Department: ${d.department ?? 'N/A'}`);
|
||||
sections.push(` Specialty: ${d.specialty ?? 'N/A'}`);
|
||||
if (d.visitingHours) sections.push(` Hours: ${d.visitingHours}`);
|
||||
if (fee) sections.push(` Consultation fee: ${fee}`);
|
||||
if (clinic) sections.push(` Clinic: ${clinic}`);
|
||||
if (clinics) sections.push(` Clinics: ${clinics}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -645,24 +726,15 @@ export class AiChatController {
|
||||
}
|
||||
|
||||
private buildSupervisorSystemPrompt(): string {
|
||||
return `You are an AI assistant for supervisors at Global Hospital's call center (Helix Engage).
|
||||
You help supervisors monitor team performance, identify issues, and make data-driven decisions.
|
||||
return this.aiConfig.renderPrompt('supervisorChat', {
|
||||
hospitalName: this.getHospitalName(),
|
||||
});
|
||||
}
|
||||
|
||||
## 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.`;
|
||||
// Best-effort hospital name lookup for the AI prompts. Falls back
|
||||
// to a generic label so prompt rendering never throws.
|
||||
private getHospitalName(): string {
|
||||
return process.env.HOSPITAL_NAME ?? 'the hospital';
|
||||
}
|
||||
|
||||
private buildRulesSystemPrompt(currentConfig: any): string {
|
||||
@@ -712,25 +784,10 @@ ${configJson}
|
||||
}
|
||||
|
||||
private buildSystemPrompt(kb: string): string {
|
||||
return `You are an AI assistant for call center agents at Global Hospital, Bangalore.
|
||||
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.
|
||||
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}`;
|
||||
return this.aiConfig.renderPrompt('ccAgentHelper', {
|
||||
hospitalName: this.getHospitalName(),
|
||||
knowledgeBase: kb,
|
||||
});
|
||||
}
|
||||
|
||||
private async chatWithTools(userMessage: string, auth: string) {
|
||||
@@ -844,16 +901,15 @@ ${kb}`;
|
||||
`{ doctors(first: 10) { edges { node {
|
||||
id name fullName { firstName lastName }
|
||||
department specialty qualifications yearsOfExperience
|
||||
visitingHours
|
||||
consultationFeeNew { amountMicros currencyCode }
|
||||
consultationFeeFollowUp { amountMicros currencyCode }
|
||||
active registrationNumber
|
||||
clinic { id name clinicName }
|
||||
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||
} } } }`,
|
||||
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 matched = doctors.filter((d: any) => {
|
||||
const full = `${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''} ${d.name ?? ''}`.toLowerCase();
|
||||
@@ -866,7 +922,13 @@ ${kb}`;
|
||||
found: true,
|
||||
doctors: matched.map((d: any) => ({
|
||||
...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',
|
||||
feeFollowUpFormatted: d.consultationFeeFollowUp ? `₹${d.consultationFeeFollowUp.amountMicros / 1_000_000}` : 'N/A',
|
||||
})),
|
||||
@@ -890,13 +952,13 @@ ${kb}`;
|
||||
try {
|
||||
const doctors = await this.platform.queryWithAuth<any>(
|
||||
`{ doctors(first: 10) { edges { node {
|
||||
name fullName { firstName lastName } department specialty visitingHours
|
||||
id name fullName { firstName lastName } department specialty
|
||||
consultationFeeNew { amountMicros currencyCode }
|
||||
clinic { name clinicName }
|
||||
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||
} } } }`,
|
||||
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 matchedDoc = docs.find((d: any) => {
|
||||
@@ -906,7 +968,7 @@ ${kb}`;
|
||||
if (matchedDoc) {
|
||||
const fee = matchedDoc.consultationFeeNew ? `₹${matchedDoc.consultationFeeNew.amountMicros / 1_000_000}` : '';
|
||||
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')) {
|
||||
|
||||
@@ -4,119 +4,110 @@ import { generateObject } from 'ai';
|
||||
import type { LanguageModel } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { createAiModel } from './ai-provider';
|
||||
import { AiConfigService } from '../config/ai-config.service';
|
||||
|
||||
type LeadContext = {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
leadSource?: string;
|
||||
interestedService?: string;
|
||||
leadStatus?: string;
|
||||
contactAttempts?: number;
|
||||
createdAt?: string;
|
||||
campaignId?: string;
|
||||
activities?: { activityType: string; summary: string }[];
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
leadSource?: string;
|
||||
interestedService?: string;
|
||||
leadStatus?: string;
|
||||
contactAttempts?: number;
|
||||
createdAt?: string;
|
||||
campaignId?: string;
|
||||
activities?: { activityType: string; summary: string }[];
|
||||
};
|
||||
|
||||
type EnrichmentResult = {
|
||||
aiSummary: string;
|
||||
aiSuggestedAction: string;
|
||||
aiSummary: string;
|
||||
aiSuggestedAction: string;
|
||||
};
|
||||
|
||||
const enrichmentSchema = z.object({
|
||||
aiSummary: z
|
||||
.string()
|
||||
.describe('1-2 sentence summary of who this lead is and their history'),
|
||||
aiSuggestedAction: z
|
||||
.string()
|
||||
.describe('5-10 word suggested action for the agent'),
|
||||
aiSummary: z.string().describe('1-2 sentence summary of who this lead is and their history'),
|
||||
aiSuggestedAction: z.string().describe('5-10 word suggested action for the agent'),
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class AiEnrichmentService {
|
||||
private readonly logger = new Logger(AiEnrichmentService.name);
|
||||
private readonly aiModel: LanguageModel | null;
|
||||
private readonly logger = new Logger(AiEnrichmentService.name);
|
||||
private readonly aiModel: LanguageModel | null;
|
||||
|
||||
constructor(private config: ConfigService) {
|
||||
this.aiModel = createAiModel(config);
|
||||
if (!this.aiModel) {
|
||||
this.logger.warn('AI not configured — enrichment uses fallback');
|
||||
}
|
||||
}
|
||||
|
||||
async enrichLead(lead: LeadContext): Promise<EnrichmentResult> {
|
||||
if (!this.aiModel) {
|
||||
return this.fallbackEnrichment(lead);
|
||||
constructor(
|
||||
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) {
|
||||
this.logger.warn('AI not configured — enrichment uses fallback');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const daysSince = lead.createdAt
|
||||
? Math.floor(
|
||||
(Date.now() - new Date(lead.createdAt).getTime()) /
|
||||
(1000 * 60 * 60 * 24),
|
||||
)
|
||||
: 0;
|
||||
async enrichLead(lead: LeadContext): Promise<EnrichmentResult> {
|
||||
if (!this.aiModel) {
|
||||
return this.fallbackEnrichment(lead);
|
||||
}
|
||||
|
||||
const activitiesText = lead.activities?.length
|
||||
? lead.activities
|
||||
.map((a) => `- ${a.activityType}: ${a.summary}`)
|
||||
.join('\n')
|
||||
: 'No previous interactions';
|
||||
try {
|
||||
const daysSince = lead.createdAt
|
||||
? Math.floor((Date.now() - new Date(lead.createdAt).getTime()) / (1000 * 60 * 60 * 24))
|
||||
: 0;
|
||||
|
||||
const { object } = await generateObject({
|
||||
model: this.aiModel,
|
||||
schema: enrichmentSchema,
|
||||
prompt: `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.
|
||||
const activitiesText = lead.activities?.length
|
||||
? lead.activities.map(a => `- ${a.activityType}: ${a.summary}`).join('\n')
|
||||
: 'No previous interactions';
|
||||
|
||||
Lead details:
|
||||
- Name: ${lead.firstName ?? 'Unknown'} ${lead.lastName ?? ''}
|
||||
- Source: ${lead.leadSource ?? 'Unknown'}
|
||||
- Interested in: ${lead.interestedService ?? 'Unknown'}
|
||||
- Current status: ${lead.leadStatus ?? 'Unknown'}
|
||||
- Lead age: ${daysSince} days
|
||||
- Contact attempts: ${lead.contactAttempts ?? 0}
|
||||
const { object } = await generateObject({
|
||||
model: this.aiModel!,
|
||||
schema: enrichmentSchema,
|
||||
prompt: this.aiConfig.renderPrompt('leadEnrichment', {
|
||||
leadName: `${lead.firstName ?? 'Unknown'} ${lead.lastName ?? ''}`.trim(),
|
||||
leadSource: lead.leadSource ?? 'Unknown',
|
||||
interestedService: lead.interestedService ?? 'Unknown',
|
||||
leadStatus: lead.leadStatus ?? 'Unknown',
|
||||
daysSince,
|
||||
contactAttempts: lead.contactAttempts ?? 0,
|
||||
activities: activitiesText,
|
||||
}),
|
||||
});
|
||||
|
||||
Recent activity:
|
||||
${activitiesText}`,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`AI enrichment generated for lead ${lead.firstName} ${lead.lastName}`,
|
||||
);
|
||||
return object;
|
||||
} catch (error) {
|
||||
this.logger.error(`AI enrichment failed: ${error}`);
|
||||
return this.fallbackEnrichment(lead);
|
||||
}
|
||||
}
|
||||
|
||||
private fallbackEnrichment(lead: LeadContext): EnrichmentResult {
|
||||
const daysSince = lead.createdAt
|
||||
? Math.floor(
|
||||
(Date.now() - new Date(lead.createdAt).getTime()) /
|
||||
(1000 * 60 * 60 * 24),
|
||||
)
|
||||
: 0;
|
||||
|
||||
const attempts = lead.contactAttempts ?? 0;
|
||||
const service = lead.interestedService ?? 'general inquiry';
|
||||
const source =
|
||||
lead.leadSource?.replace(/_/g, ' ').toLowerCase() ?? 'unknown source';
|
||||
|
||||
let summary: string;
|
||||
let action: string;
|
||||
|
||||
if (attempts === 0) {
|
||||
summary = `First-time inquiry from ${source}. Interested in ${service}. Lead is ${daysSince} day(s) old with no previous contact.`;
|
||||
action = `Introduce services and offer appointment booking`;
|
||||
} else if (attempts === 1) {
|
||||
summary = `Returning inquiry — contacted once before. Interested in ${service} via ${source}. Lead is ${daysSince} day(s) old.`;
|
||||
action = `Follow up on previous conversation, offer appointment`;
|
||||
} else {
|
||||
summary = `Repeat contact (${attempts} previous attempts) for ${service}. Originally from ${source}, ${daysSince} day(s) old. Shows high interest.`;
|
||||
action = `Prioritize appointment booking — high-intent lead`;
|
||||
this.logger.log(`AI enrichment generated for lead ${lead.firstName} ${lead.lastName}`);
|
||||
return object;
|
||||
} catch (error) {
|
||||
this.logger.error(`AI enrichment failed: ${error}`);
|
||||
return this.fallbackEnrichment(lead);
|
||||
}
|
||||
}
|
||||
|
||||
return { aiSummary: summary, aiSuggestedAction: action };
|
||||
}
|
||||
private fallbackEnrichment(lead: LeadContext): EnrichmentResult {
|
||||
const daysSince = lead.createdAt
|
||||
? Math.floor((Date.now() - new Date(lead.createdAt).getTime()) / (1000 * 60 * 60 * 24))
|
||||
: 0;
|
||||
|
||||
const attempts = lead.contactAttempts ?? 0;
|
||||
const service = lead.interestedService ?? 'general inquiry';
|
||||
const source = lead.leadSource?.replace(/_/g, ' ').toLowerCase() ?? 'unknown source';
|
||||
|
||||
let summary: string;
|
||||
let action: string;
|
||||
|
||||
if (attempts === 0) {
|
||||
summary = `First-time inquiry from ${source}. Interested in ${service}. Lead is ${daysSince} day(s) old with no previous contact.`;
|
||||
action = `Introduce services and offer appointment booking`;
|
||||
} else if (attempts === 1) {
|
||||
summary = `Returning inquiry — contacted once before. Interested in ${service} via ${source}. Lead is ${daysSince} day(s) old.`;
|
||||
action = `Follow up on previous conversation, offer appointment`;
|
||||
} else {
|
||||
summary = `Repeat contact (${attempts} previous attempts) for ${service}. Originally from ${source}, ${daysSince} day(s) old. Shows high interest.`;
|
||||
action = `Prioritize appointment booking — high-intent lead`;
|
||||
}
|
||||
|
||||
return { aiSummary: summary, aiSuggestedAction: action };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,30 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { anthropic } from '@ai-sdk/anthropic';
|
||||
import { openai } from '@ai-sdk/openai';
|
||||
import type { LanguageModel } from 'ai';
|
||||
|
||||
export function createAiModel(config: ConfigService): LanguageModel | null {
|
||||
const provider = config.get<string>('ai.provider') ?? 'openai';
|
||||
const model = config.get<string>('ai.model') ?? 'gpt-4o-mini';
|
||||
// Pure factory — no DI. Caller passes provider/model (admin-editable, from
|
||||
// AiConfigService) and the API key (env-driven, ops-owned). Decoupling means
|
||||
// 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') {
|
||||
const apiKey = config.get<string>('ai.anthropicApiKey');
|
||||
if (!apiKey) return null;
|
||||
return anthropic(model);
|
||||
}
|
||||
export type AiProviderOpts = {
|
||||
provider: string;
|
||||
model: string;
|
||||
anthropicApiKey?: string;
|
||||
openaiApiKey?: string;
|
||||
};
|
||||
|
||||
// Default to openai
|
||||
const apiKey = config.get<string>('ai.openaiApiKey');
|
||||
if (!apiKey) return null;
|
||||
return openai(model);
|
||||
export function createAiModel(opts: AiProviderOpts): LanguageModel | null {
|
||||
if (opts.provider === 'anthropic') {
|
||||
if (!opts.anthropicApiKey) return null;
|
||||
return anthropic(opts.model);
|
||||
}
|
||||
// Default to openai
|
||||
if (!opts.openaiApiKey) return null;
|
||||
return openai(opts.model);
|
||||
}
|
||||
|
||||
export function isAiConfigured(config: ConfigService): boolean {
|
||||
const provider = config.get<string>('ai.provider') ?? 'openai';
|
||||
if (provider === 'anthropic')
|
||||
return !!config.get<string>('ai.anthropicApiKey');
|
||||
return !!config.get<string>('ai.openaiApiKey');
|
||||
export function isAiConfigured(opts: AiProviderOpts): boolean {
|
||||
if (opts.provider === 'anthropic') return !!opts.anthropicApiKey;
|
||||
return !!opts.openaiApiKey;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { PlatformModule } from '../platform/platform.module';
|
||||
import { AiEnrichmentService } from './ai-enrichment.service';
|
||||
import { AiChatController } from './ai-chat.controller';
|
||||
import { CallerResolutionModule } from '../caller/caller-resolution.module';
|
||||
|
||||
@Module({
|
||||
imports: [PlatformModule],
|
||||
controllers: [AiChatController],
|
||||
providers: [AiEnrichmentService],
|
||||
exports: [AiEnrichmentService],
|
||||
imports: [PlatformModule, forwardRef(() => CallerResolutionModule)],
|
||||
controllers: [AiChatController],
|
||||
providers: [AiEnrichmentService],
|
||||
exports: [AiEnrichmentService],
|
||||
})
|
||||
export class AiModule {}
|
||||
|
||||
@@ -19,6 +19,11 @@ import { EventsModule } from './events/events.module';
|
||||
import { CallerResolutionModule } from './caller/caller-resolution.module';
|
||||
import { RulesEngineModule } from './rules-engine/rules-engine.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({
|
||||
imports: [
|
||||
@@ -44,6 +49,11 @@ import { ConfigThemeModule } from './config/config-theme.module';
|
||||
CallerResolutionModule,
|
||||
RulesEngineModule,
|
||||
ConfigThemeModule,
|
||||
WidgetModule,
|
||||
TeamModule,
|
||||
MasterdataModule,
|
||||
LeadsModule,
|
||||
],
|
||||
providers: [TelephonyRegistrationService],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -1,78 +1,82 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||
|
||||
export type AgentConfig = {
|
||||
id: string;
|
||||
ozonetelAgentId: string;
|
||||
sipExtension: string;
|
||||
sipPassword: string;
|
||||
campaignName: string;
|
||||
sipUri: string;
|
||||
sipWsServer: string;
|
||||
id: string;
|
||||
ozonetelAgentId: string;
|
||||
sipExtension: string;
|
||||
sipPassword: string;
|
||||
campaignName: string;
|
||||
sipUri: string;
|
||||
sipWsServer: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AgentConfigService {
|
||||
private readonly logger = new Logger(AgentConfigService.name);
|
||||
private readonly cache = new Map<string, AgentConfig>();
|
||||
private readonly sipDomain: string;
|
||||
private readonly sipWsPort: string;
|
||||
private readonly logger = new Logger(AgentConfigService.name);
|
||||
private readonly cache = new Map<string, AgentConfig>();
|
||||
|
||||
constructor(
|
||||
private platform: PlatformGraphqlService,
|
||||
private config: ConfigService,
|
||||
) {
|
||||
this.sipDomain = config.get<string>(
|
||||
'sip.domain',
|
||||
'blr-pub-rtc4.ozonetel.com',
|
||||
);
|
||||
this.sipWsPort = config.get<string>('sip.wsPort', '444');
|
||||
}
|
||||
constructor(
|
||||
private platform: PlatformGraphqlService,
|
||||
private telephony: TelephonyConfigService,
|
||||
) {}
|
||||
|
||||
async getByMemberId(memberId: string): Promise<AgentConfig | null> {
|
||||
const cached = this.cache.get(memberId);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const data = await this.platform.query<any>(
|
||||
`{ agents(first: 1, filter: { wsmemberId: { eq: "${memberId}" } }) { edges { node {
|
||||
id ozonetelagentid sipextension sippassword campaignname
|
||||
} } } }`,
|
||||
);
|
||||
|
||||
const node = data?.agents?.edges?.[0]?.node;
|
||||
if (!node || !node.ozonetelagentid || !node.sipextension) return null;
|
||||
|
||||
const agentConfig: AgentConfig = {
|
||||
id: node.id,
|
||||
ozonetelAgentId: node.ozonetelagentid,
|
||||
sipExtension: node.sipextension,
|
||||
sipPassword: node.sippassword ?? node.sipextension,
|
||||
campaignName:
|
||||
node.campaignname ??
|
||||
process.env.OZONETEL_CAMPAIGN_NAME ??
|
||||
'Inbound_918041763265',
|
||||
sipUri: `sip:${node.sipextension}@${this.sipDomain}`,
|
||||
sipWsServer: `wss://${this.sipDomain}:${this.sipWsPort}`,
|
||||
};
|
||||
|
||||
this.cache.set(memberId, agentConfig);
|
||||
this.logger.log(
|
||||
`Loaded agent config for member ${memberId}: ${agentConfig.ozonetelAgentId} / ${agentConfig.sipExtension}`,
|
||||
);
|
||||
return agentConfig;
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to fetch agent config: ${err}`);
|
||||
return null;
|
||||
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 {
|
||||
return this.telephony.getConfig().ozonetel.campaignName || 'Inbound_918041763265';
|
||||
}
|
||||
}
|
||||
|
||||
getFromCache(memberId: string): AgentConfig | null {
|
||||
return this.cache.get(memberId) ?? null;
|
||||
}
|
||||
async getByMemberId(memberId: string): Promise<AgentConfig | null> {
|
||||
const cached = this.cache.get(memberId);
|
||||
if (cached) return cached;
|
||||
|
||||
clearCache(memberId: string): void {
|
||||
this.cache.delete(memberId);
|
||||
}
|
||||
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>(
|
||||
`{ agents(first: 1, filter: { workspaceMemberId: { eq: "${memberId}" } }) { edges { node {
|
||||
id ozonetelAgentId sipExtension sipPassword campaignName
|
||||
} } } }`,
|
||||
);
|
||||
|
||||
const node = data?.agents?.edges?.[0]?.node;
|
||||
if (!node || !node.ozonetelAgentId || !node.sipExtension) return null;
|
||||
|
||||
const agentConfig: AgentConfig = {
|
||||
id: node.id,
|
||||
ozonetelAgentId: node.ozonetelAgentId,
|
||||
sipExtension: node.sipExtension,
|
||||
sipPassword: node.sipPassword ?? node.sipExtension,
|
||||
campaignName: node.campaignName ?? this.defaultCampaignName,
|
||||
sipUri: `sip:${node.sipExtension}@${this.sipDomain}`,
|
||||
sipWsServer: `wss://${this.sipDomain}:${this.sipWsPort}`,
|
||||
};
|
||||
|
||||
this.cache.set(memberId, agentConfig);
|
||||
this.logger.log(`Loaded agent config for member ${memberId}: ${agentConfig.ozonetelAgentId} / ${agentConfig.sipExtension}`);
|
||||
return agentConfig;
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to fetch agent config: ${err}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
getFromCache(memberId: string): AgentConfig | null {
|
||||
return this.cache.get(memberId) ?? null;
|
||||
}
|
||||
|
||||
clearCache(memberId: string): void {
|
||||
this.cache.delete(memberId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import axios from 'axios';
|
||||
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
||||
import { SessionService } from './session.service';
|
||||
import { AgentConfigService } from './agent-config.service';
|
||||
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
@@ -18,6 +19,7 @@ export class AuthController {
|
||||
private ozonetelAgent: OzonetelAgentService,
|
||||
private sessionService: SessionService,
|
||||
private agentConfigService: AgentConfigService,
|
||||
private telephony: TelephonyConfigService,
|
||||
) {
|
||||
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
|
||||
this.workspaceSubdomain = process.env.PLATFORM_WORKSPACE_SUBDOMAIN ?? 'fortytwo-dev';
|
||||
@@ -105,11 +107,9 @@ export class AuthController {
|
||||
|
||||
// Determine app role from platform roles
|
||||
let appRole = 'executive'; // default
|
||||
if (roleLabels.includes('HelixEngage Manager')) {
|
||||
if (roleLabels.includes('HelixEngage Manager') || roleLabels.includes('HelixEngage Supervisor')) {
|
||||
appRole = 'admin';
|
||||
} 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;
|
||||
appRole = email.includes('cc') ? 'cc-agent' : 'executive';
|
||||
}
|
||||
@@ -138,7 +138,7 @@ export class AuthController {
|
||||
this.logger.warn(`Ozonetel token refresh on login failed: ${err.message}`);
|
||||
});
|
||||
|
||||
const ozAgentPassword = process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$';
|
||||
const ozAgentPassword = this.telephony.getConfig().ozonetel.agentPassword || 'Test123$';
|
||||
this.ozonetelAgent.loginAgent({
|
||||
agentId: agentConfig.ozonetelAgentId,
|
||||
password: ozAgentPassword,
|
||||
@@ -252,7 +252,7 @@ export class AuthController {
|
||||
|
||||
this.ozonetelAgent.logoutAgent({
|
||||
agentId: agentConfig.ozonetelAgentId,
|
||||
password: process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$',
|
||||
password: this.telephony.getConfig().ozonetel.agentPassword || 'Test123$',
|
||||
}).catch(err => this.logger.warn(`Ozonetel logout failed: ${err.message}`));
|
||||
|
||||
this.agentConfigService.clearCache(memberId);
|
||||
|
||||
@@ -6,9 +6,9 @@ import { SessionService } from './session.service';
|
||||
import { AgentConfigService } from './agent-config.service';
|
||||
|
||||
@Module({
|
||||
imports: [OzonetelAgentModule, PlatformModule],
|
||||
controllers: [AuthController],
|
||||
providers: [SessionService, AgentConfigService],
|
||||
exports: [SessionService, AgentConfigService],
|
||||
imports: [OzonetelAgentModule, PlatformModule],
|
||||
controllers: [AuthController],
|
||||
providers: [SessionService, AgentConfigService],
|
||||
exports: [SessionService, AgentConfigService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
const SESSION_TTL = 3600; // 1 hour
|
||||
|
||||
@Injectable()
|
||||
export class SessionService implements OnModuleInit {
|
||||
export class SessionService {
|
||||
private readonly logger = new Logger(SessionService.name);
|
||||
private redis: Redis;
|
||||
private readonly redis: Redis;
|
||||
|
||||
constructor(private config: ConfigService) {}
|
||||
|
||||
onModuleInit() {
|
||||
// Redis client is constructed eagerly (not in onModuleInit) so
|
||||
// other services can call cache methods from THEIR 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');
|
||||
this.redis = new Redis(url);
|
||||
this.redis = new Redis(url, { lazyConnect: false });
|
||||
this.redis.on('connect', () => this.logger.log('Redis connected'));
|
||||
this.redis.on('error', (err) => this.logger.error(`Redis error: ${err.message}`));
|
||||
}
|
||||
@@ -60,6 +64,10 @@ export class SessionService implements OnModuleInit {
|
||||
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> {
|
||||
await this.redis.del(key);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
WebSocketGateway,
|
||||
SubscribeMessage,
|
||||
MessageBody,
|
||||
ConnectedSocket,
|
||||
OnGatewayDisconnect,
|
||||
WebSocketGateway,
|
||||
SubscribeMessage,
|
||||
MessageBody,
|
||||
ConnectedSocket,
|
||||
OnGatewayDisconnect,
|
||||
} from '@nestjs/websockets';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Socket } from 'socket.io';
|
||||
@@ -11,138 +11,126 @@ import WebSocket from 'ws';
|
||||
import { CallAssistService } from './call-assist.service';
|
||||
|
||||
type SessionState = {
|
||||
deepgramWs: WebSocket | null;
|
||||
transcript: string;
|
||||
context: string;
|
||||
suggestionTimer: NodeJS.Timeout | null;
|
||||
deepgramWs: WebSocket | null;
|
||||
transcript: string;
|
||||
context: string;
|
||||
suggestionTimer: NodeJS.Timeout | null;
|
||||
};
|
||||
|
||||
@WebSocketGateway({
|
||||
cors: { origin: process.env.CORS_ORIGIN ?? '*', credentials: true },
|
||||
namespace: '/call-assist',
|
||||
cors: { origin: process.env.CORS_ORIGIN ?? '*', credentials: true },
|
||||
namespace: '/call-assist',
|
||||
})
|
||||
export class CallAssistGateway implements OnGatewayDisconnect {
|
||||
private readonly logger = new Logger(CallAssistGateway.name);
|
||||
private readonly sessions = new Map<string, SessionState>();
|
||||
private readonly deepgramApiKey: string;
|
||||
private readonly logger = new Logger(CallAssistGateway.name);
|
||||
private readonly sessions = new Map<string, SessionState>();
|
||||
private readonly deepgramApiKey: string;
|
||||
|
||||
constructor(private readonly callAssist: CallAssistService) {
|
||||
this.deepgramApiKey = process.env.DEEPGRAM_API_KEY ?? '';
|
||||
}
|
||||
|
||||
@SubscribeMessage('call-assist:start')
|
||||
async handleStart(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody()
|
||||
data: { ucid: string; leadId?: string; callerPhone?: string },
|
||||
) {
|
||||
this.logger.log(
|
||||
`Call assist start: ucid=${data.ucid} lead=${data.leadId ?? 'none'}`,
|
||||
);
|
||||
|
||||
const context = await this.callAssist.loadCallContext(
|
||||
data.leadId ?? null,
|
||||
data.callerPhone ?? null,
|
||||
);
|
||||
client.emit('call-assist:context', {
|
||||
context: context.substring(0, 200) + '...',
|
||||
});
|
||||
|
||||
const session: SessionState = {
|
||||
deepgramWs: null,
|
||||
transcript: '',
|
||||
context,
|
||||
suggestionTimer: null,
|
||||
};
|
||||
|
||||
if (this.deepgramApiKey) {
|
||||
const dgUrl = `wss://api.deepgram.com/v1/listen?model=nova-2&language=en&smart_format=true&interim_results=true&endpointing=300&sample_rate=16000&encoding=linear16&channels=1`;
|
||||
|
||||
const dgWs = new WebSocket(dgUrl, {
|
||||
headers: { Authorization: `Token ${this.deepgramApiKey}` },
|
||||
});
|
||||
|
||||
dgWs.on('open', () => {
|
||||
this.logger.log(`Deepgram connected for ${data.ucid}`);
|
||||
});
|
||||
|
||||
dgWs.on('message', (raw: WebSocket.Data) => {
|
||||
try {
|
||||
const result = JSON.parse(raw.toString());
|
||||
const text = result.channel?.alternatives?.[0]?.transcript;
|
||||
if (!text) return;
|
||||
|
||||
const isFinal = result.is_final;
|
||||
client.emit('call-assist:transcript', { text, isFinal });
|
||||
|
||||
if (isFinal) {
|
||||
session.transcript += `Customer: ${text}\n`;
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
|
||||
dgWs.on('error', (err) => {
|
||||
this.logger.error(`Deepgram error: ${err.message}`);
|
||||
});
|
||||
|
||||
dgWs.on('close', () => {
|
||||
this.logger.log(`Deepgram closed for ${data.ucid}`);
|
||||
});
|
||||
|
||||
session.deepgramWs = dgWs;
|
||||
} else {
|
||||
this.logger.warn('DEEPGRAM_API_KEY not set — transcription disabled');
|
||||
client.emit('call-assist:error', {
|
||||
message: 'Transcription not configured',
|
||||
});
|
||||
constructor(private readonly callAssist: CallAssistService) {
|
||||
this.deepgramApiKey = process.env.DEEPGRAM_API_KEY ?? '';
|
||||
}
|
||||
|
||||
// AI suggestion every 10 seconds
|
||||
session.suggestionTimer = setInterval(async () => {
|
||||
if (!session.transcript.trim()) return;
|
||||
const suggestion = await this.callAssist.getSuggestion(
|
||||
session.transcript,
|
||||
session.context,
|
||||
);
|
||||
if (suggestion) {
|
||||
client.emit('call-assist:suggestion', { text: suggestion });
|
||||
}
|
||||
}, 10000);
|
||||
@SubscribeMessage('call-assist:start')
|
||||
async handleStart(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() data: { ucid: string; leadId?: string; callerPhone?: string },
|
||||
) {
|
||||
this.logger.log(`Call assist start: ucid=${data.ucid} lead=${data.leadId ?? 'none'}`);
|
||||
|
||||
this.sessions.set(client.id, session);
|
||||
}
|
||||
const context = await this.callAssist.loadCallContext(
|
||||
data.leadId ?? null,
|
||||
data.callerPhone ?? null,
|
||||
);
|
||||
client.emit('call-assist:context', { context: context.substring(0, 200) + '...' });
|
||||
|
||||
@SubscribeMessage('call-assist:audio')
|
||||
handleAudio(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() audioData: ArrayBuffer,
|
||||
) {
|
||||
const session = this.sessions.get(client.id);
|
||||
if (session?.deepgramWs?.readyState === WebSocket.OPEN) {
|
||||
session.deepgramWs.send(Buffer.from(audioData));
|
||||
const session: SessionState = {
|
||||
deepgramWs: null,
|
||||
transcript: '',
|
||||
context,
|
||||
suggestionTimer: null,
|
||||
};
|
||||
|
||||
if (this.deepgramApiKey) {
|
||||
const dgUrl = `wss://api.deepgram.com/v1/listen?model=nova-2&language=en&smart_format=true&interim_results=true&endpointing=300&sample_rate=16000&encoding=linear16&channels=1`;
|
||||
|
||||
const dgWs = new WebSocket(dgUrl, {
|
||||
headers: { Authorization: `Token ${this.deepgramApiKey}` },
|
||||
});
|
||||
|
||||
dgWs.on('open', () => {
|
||||
this.logger.log(`Deepgram connected for ${data.ucid}`);
|
||||
});
|
||||
|
||||
dgWs.on('message', (raw: WebSocket.Data) => {
|
||||
try {
|
||||
const result = JSON.parse(raw.toString());
|
||||
const text = result.channel?.alternatives?.[0]?.transcript;
|
||||
if (!text) return;
|
||||
|
||||
const isFinal = result.is_final;
|
||||
client.emit('call-assist:transcript', { text, isFinal });
|
||||
|
||||
if (isFinal) {
|
||||
session.transcript += `Customer: ${text}\n`;
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
|
||||
dgWs.on('error', (err) => {
|
||||
this.logger.error(`Deepgram error: ${err.message}`);
|
||||
});
|
||||
|
||||
dgWs.on('close', () => {
|
||||
this.logger.log(`Deepgram closed for ${data.ucid}`);
|
||||
});
|
||||
|
||||
session.deepgramWs = dgWs;
|
||||
} else {
|
||||
this.logger.warn('DEEPGRAM_API_KEY not set — transcription disabled');
|
||||
client.emit('call-assist:error', { message: 'Transcription not configured' });
|
||||
}
|
||||
|
||||
// AI suggestion every 10 seconds
|
||||
session.suggestionTimer = setInterval(async () => {
|
||||
if (!session.transcript.trim()) return;
|
||||
const suggestion = await this.callAssist.getSuggestion(session.transcript, session.context);
|
||||
if (suggestion) {
|
||||
client.emit('call-assist:suggestion', { text: suggestion });
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
this.sessions.set(client.id, session);
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage('call-assist:stop')
|
||||
handleStop(@ConnectedSocket() client: Socket) {
|
||||
this.cleanup(client.id);
|
||||
this.logger.log(`Call assist stopped: ${client.id}`);
|
||||
}
|
||||
|
||||
handleDisconnect(client: Socket) {
|
||||
this.cleanup(client.id);
|
||||
}
|
||||
|
||||
private cleanup(clientId: string) {
|
||||
const session = this.sessions.get(clientId);
|
||||
if (session) {
|
||||
if (session.suggestionTimer) clearInterval(session.suggestionTimer);
|
||||
if (session.deepgramWs) {
|
||||
try {
|
||||
session.deepgramWs.close();
|
||||
} catch {}
|
||||
}
|
||||
this.sessions.delete(clientId);
|
||||
@SubscribeMessage('call-assist:audio')
|
||||
handleAudio(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() audioData: ArrayBuffer,
|
||||
) {
|
||||
const session = this.sessions.get(client.id);
|
||||
if (session?.deepgramWs?.readyState === WebSocket.OPEN) {
|
||||
session.deepgramWs.send(Buffer.from(audioData));
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage('call-assist:stop')
|
||||
handleStop(@ConnectedSocket() client: Socket) {
|
||||
this.cleanup(client.id);
|
||||
this.logger.log(`Call assist stopped: ${client.id}`);
|
||||
}
|
||||
|
||||
handleDisconnect(client: Socket) {
|
||||
this.cleanup(client.id);
|
||||
}
|
||||
|
||||
private cleanup(clientId: string) {
|
||||
const session = this.sessions.get(clientId);
|
||||
if (session) {
|
||||
if (session.suggestionTimer) clearInterval(session.suggestionTimer);
|
||||
if (session.deepgramWs) {
|
||||
try { session.deepgramWs.close(); } catch {}
|
||||
}
|
||||
this.sessions.delete(clientId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { CallAssistService } from './call-assist.service';
|
||||
import { PlatformModule } from '../platform/platform.module';
|
||||
|
||||
@Module({
|
||||
imports: [PlatformModule],
|
||||
providers: [CallAssistGateway, CallAssistService],
|
||||
imports: [PlatformModule],
|
||||
providers: [CallAssistGateway, CallAssistService],
|
||||
})
|
||||
export class CallAssistModule {}
|
||||
|
||||
@@ -4,140 +4,129 @@ import { generateText } from 'ai';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { createAiModel } from '../ai/ai-provider';
|
||||
import type { LanguageModel } from 'ai';
|
||||
import { AiConfigService } from '../config/ai-config.service';
|
||||
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../shared/doctor-utils';
|
||||
|
||||
@Injectable()
|
||||
export class CallAssistService {
|
||||
private readonly logger = new Logger(CallAssistService.name);
|
||||
private readonly aiModel: LanguageModel | null;
|
||||
private readonly platformApiKey: string;
|
||||
private readonly logger = new Logger(CallAssistService.name);
|
||||
private readonly aiModel: LanguageModel | null;
|
||||
private readonly platformApiKey: string;
|
||||
|
||||
constructor(
|
||||
private config: ConfigService,
|
||||
private platform: PlatformGraphqlService,
|
||||
) {
|
||||
this.aiModel = createAiModel(config);
|
||||
this.platformApiKey = config.get<string>('platform.apiKey') ?? '';
|
||||
}
|
||||
constructor(
|
||||
private config: ConfigService,
|
||||
private platform: PlatformGraphqlService,
|
||||
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'),
|
||||
});
|
||||
this.platformApiKey = config.get<string>('platform.apiKey') ?? '';
|
||||
}
|
||||
|
||||
async loadCallContext(
|
||||
leadId: string | null,
|
||||
callerPhone: string | null,
|
||||
): Promise<string> {
|
||||
const authHeader = this.platformApiKey
|
||||
? `Bearer ${this.platformApiKey}`
|
||||
: '';
|
||||
if (!authHeader) return 'No platform context available.';
|
||||
async loadCallContext(leadId: string | null, callerPhone: string | null): Promise<string> {
|
||||
const authHeader = this.platformApiKey ? `Bearer ${this.platformApiKey}` : '';
|
||||
if (!authHeader) return 'No platform context available.';
|
||||
|
||||
try {
|
||||
const parts: string[] = [];
|
||||
try {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (leadId) {
|
||||
const leadResult = await this.platform.queryWithAuth<any>(
|
||||
`{ leads(filter: { id: { eq: "${leadId}" } }) { edges { node {
|
||||
if (leadId) {
|
||||
const leadResult = await this.platform.queryWithAuth<any>(
|
||||
`{ leads(filter: { id: { eq: "${leadId}" } }) { edges { node {
|
||||
id name contactName { firstName lastName }
|
||||
contactPhone { primaryPhoneNumber }
|
||||
source status interestedService
|
||||
lastContacted contactAttempts
|
||||
aiSummary aiSuggestedAction
|
||||
} } } }`,
|
||||
undefined,
|
||||
authHeader,
|
||||
);
|
||||
const lead = leadResult.leads.edges[0]?.node;
|
||||
if (lead) {
|
||||
const name = lead.contactName
|
||||
? `${lead.contactName.firstName} ${lead.contactName.lastName}`.trim()
|
||||
: lead.name;
|
||||
parts.push(`CALLER: ${name}`);
|
||||
parts.push(
|
||||
`Phone: ${lead.contactPhone?.primaryPhoneNumber ?? callerPhone}`,
|
||||
);
|
||||
parts.push(`Source: ${lead.source ?? 'Unknown'}`);
|
||||
parts.push(
|
||||
`Interested in: ${lead.interestedService ?? 'Not specified'}`,
|
||||
);
|
||||
parts.push(`Contact attempts: ${lead.contactAttempts ?? 0}`);
|
||||
if (lead.aiSummary) parts.push(`AI Summary: ${lead.aiSummary}`);
|
||||
}
|
||||
undefined, authHeader,
|
||||
);
|
||||
const lead = leadResult.leads.edges[0]?.node;
|
||||
if (lead) {
|
||||
const name = lead.contactName
|
||||
? `${lead.contactName.firstName} ${lead.contactName.lastName}`.trim()
|
||||
: lead.name;
|
||||
parts.push(`CALLER: ${name}`);
|
||||
parts.push(`Phone: ${lead.contactPhone?.primaryPhoneNumber ?? callerPhone}`);
|
||||
parts.push(`Source: ${lead.source ?? 'Unknown'}`);
|
||||
parts.push(`Interested in: ${lead.interestedService ?? 'Not specified'}`);
|
||||
parts.push(`Contact attempts: ${lead.contactAttempts ?? 0}`);
|
||||
if (lead.aiSummary) parts.push(`AI Summary: ${lead.aiSummary}`);
|
||||
}
|
||||
|
||||
const apptResult = await this.platform.queryWithAuth<any>(
|
||||
`{ appointments(first: 10, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||
const apptResult = await this.platform.queryWithAuth<any>(
|
||||
`{ appointments(first: 10, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||
id scheduledAt status doctorName department reasonForVisit patientId
|
||||
} } } }`,
|
||||
undefined,
|
||||
authHeader,
|
||||
);
|
||||
const appts = apptResult.appointments.edges
|
||||
.map((e: any) => e.node)
|
||||
.filter((a: any) => a.patientId === leadId);
|
||||
if (appts.length > 0) {
|
||||
parts.push('\nPAST APPOINTMENTS:');
|
||||
for (const a of appts) {
|
||||
const date = a.scheduledAt
|
||||
? new Date(a.scheduledAt).toLocaleDateString('en-IN')
|
||||
: '?';
|
||||
parts.push(
|
||||
`- ${date}: ${a.doctorName ?? '?'} (${a.department ?? '?'}) — ${a.status}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (callerPhone) {
|
||||
parts.push(`CALLER: Unknown (${callerPhone})`);
|
||||
parts.push('No lead record found — this may be a new enquiry.');
|
||||
}
|
||||
undefined, authHeader,
|
||||
);
|
||||
const appts = apptResult.appointments.edges
|
||||
.map((e: any) => e.node)
|
||||
.filter((a: any) => a.patientId === leadId);
|
||||
if (appts.length > 0) {
|
||||
parts.push('\nPAST APPOINTMENTS:');
|
||||
for (const a of appts) {
|
||||
const date = a.scheduledAt ? new Date(a.scheduledAt).toLocaleDateString('en-IN') : '?';
|
||||
parts.push(`- ${date}: ${a.doctorName ?? '?'} (${a.department ?? '?'}) — ${a.status}`);
|
||||
}
|
||||
}
|
||||
} else if (callerPhone) {
|
||||
parts.push(`CALLER: Unknown (${callerPhone})`);
|
||||
parts.push('No lead record found — this may be a new enquiry.');
|
||||
}
|
||||
|
||||
const docResult = await this.platform.queryWithAuth<any>(
|
||||
`{ doctors(first: 20) { edges { node {
|
||||
fullName { firstName lastName } department specialty clinic { clinicName }
|
||||
const docResult = await this.platform.queryWithAuth<any>(
|
||||
`{ doctors(first: 20) { edges { node {
|
||||
id fullName { firstName lastName } department specialty
|
||||
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||
} } } }`,
|
||||
undefined,
|
||||
authHeader,
|
||||
);
|
||||
const docs = docResult.doctors.edges.map((e: any) => e.node);
|
||||
if (docs.length > 0) {
|
||||
parts.push('\nAVAILABLE DOCTORS:');
|
||||
for (const d of docs) {
|
||||
const name = d.fullName
|
||||
? `Dr. ${d.fullName.firstName} ${d.fullName.lastName}`.trim()
|
||||
: 'Unknown';
|
||||
parts.push(
|
||||
`- ${name} — ${d.department ?? '?'} — ${d.clinic?.clinicName ?? '?'}`,
|
||||
);
|
||||
undefined, authHeader,
|
||||
);
|
||||
const docs = normalizeDoctors(docResult.doctors.edges.map((e: any) => e.node));
|
||||
if (docs.length > 0) {
|
||||
parts.push('\nAVAILABLE DOCTORS:');
|
||||
for (const d of docs) {
|
||||
const name = d.fullName ? `Dr. ${d.fullName.firstName} ${d.fullName.lastName}`.trim() : 'Unknown';
|
||||
// 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('\n') || 'No context available.';
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to load call context: ${err}`);
|
||||
return 'Context loading failed.';
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('\n') || 'No context available.';
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to load call context: ${err}`);
|
||||
return 'Context loading failed.';
|
||||
}
|
||||
}
|
||||
|
||||
async getSuggestion(transcript: string, context: string): Promise<string> {
|
||||
if (!this.aiModel || !transcript.trim()) return '';
|
||||
async getSuggestion(transcript: string, context: string): Promise<string> {
|
||||
if (!this.aiModel || !transcript.trim()) return '';
|
||||
|
||||
try {
|
||||
const { text } = await generateText({
|
||||
model: this.aiModel,
|
||||
system: `You are a real-time call assistant for Global Hospital Bangalore.
|
||||
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`,
|
||||
prompt: `Conversation transcript so far:\n${transcript}\n\nProvide a brief suggestion for the agent based on what was just said.`,
|
||||
maxOutputTokens: 150,
|
||||
});
|
||||
return text;
|
||||
} catch (err) {
|
||||
this.logger.error(`AI suggestion failed: ${err}`);
|
||||
return '';
|
||||
try {
|
||||
const { text } = await generateText({
|
||||
model: this.aiModel,
|
||||
system: this.aiConfig.renderPrompt('callAssist', {
|
||||
hospitalName: process.env.HOSPITAL_NAME ?? 'the hospital',
|
||||
context,
|
||||
}),
|
||||
prompt: `Conversation transcript so far:\n${transcript}\n\nProvide a brief suggestion for the agent based on what was just said.`,
|
||||
maxOutputTokens: 150,
|
||||
});
|
||||
return text;
|
||||
} catch (err) {
|
||||
this.logger.error(`AI suggestion failed: ${err}`);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PlatformModule } from '../platform/platform.module';
|
||||
import { AiModule } from '../ai/ai.module';
|
||||
import { CallerResolutionModule } from '../caller/caller-resolution.module';
|
||||
import { CallEventsService } from './call-events.service';
|
||||
import { CallEventsGateway } from './call-events.gateway';
|
||||
import { CallLookupController } from './call-lookup.controller';
|
||||
import { LeadEnrichController } from './lead-enrich.controller';
|
||||
|
||||
@Module({
|
||||
imports: [PlatformModule, AiModule],
|
||||
controllers: [CallLookupController],
|
||||
providers: [CallEventsService, CallEventsGateway],
|
||||
exports: [CallEventsService, CallEventsGateway],
|
||||
// CallerResolutionModule is imported so LeadEnrichController can
|
||||
// inject CallerResolutionService to invalidate the Redis caller
|
||||
// cache after a forced re-enrichment.
|
||||
imports: [PlatformModule, AiModule, CallerResolutionModule],
|
||||
controllers: [CallLookupController, LeadEnrichController],
|
||||
providers: [CallEventsService, CallEventsGateway],
|
||||
exports: [CallEventsService, CallEventsGateway],
|
||||
})
|
||||
export class CallEventsModule {}
|
||||
|
||||
@@ -1,38 +1,38 @@
|
||||
export type EnrichedCallEvent = {
|
||||
callSid: string;
|
||||
eventType: 'ringing' | 'answered' | 'ended';
|
||||
lead: {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone: string;
|
||||
email?: string;
|
||||
source?: string;
|
||||
status?: string;
|
||||
campaign?: string;
|
||||
interestedService?: string;
|
||||
age: number;
|
||||
aiSummary?: string;
|
||||
aiSuggestedAction?: string;
|
||||
recentActivities: {
|
||||
activityType: string;
|
||||
summary: string;
|
||||
occurredAt: string;
|
||||
performedBy: string;
|
||||
}[];
|
||||
} | null;
|
||||
callerPhone: string;
|
||||
agentName: string;
|
||||
timestamp: string;
|
||||
callSid: string;
|
||||
eventType: 'ringing' | 'answered' | 'ended';
|
||||
lead: {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone: string;
|
||||
email?: string;
|
||||
source?: string;
|
||||
status?: string;
|
||||
campaign?: string;
|
||||
interestedService?: string;
|
||||
age: number;
|
||||
aiSummary?: string;
|
||||
aiSuggestedAction?: string;
|
||||
recentActivities: {
|
||||
activityType: string;
|
||||
summary: string;
|
||||
occurredAt: string;
|
||||
performedBy: string;
|
||||
}[];
|
||||
} | null;
|
||||
callerPhone: string;
|
||||
agentName: string;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
export type DispositionPayload = {
|
||||
callSid: string;
|
||||
leadId: string | null;
|
||||
disposition: string;
|
||||
notes: string;
|
||||
agentName: string;
|
||||
callerPhone: string;
|
||||
startedAt: string;
|
||||
duration: number;
|
||||
callSid: string;
|
||||
leadId: string | null;
|
||||
disposition: string;
|
||||
notes: string;
|
||||
agentName: string;
|
||||
callerPhone: string;
|
||||
startedAt: string;
|
||||
duration: number;
|
||||
};
|
||||
|
||||
@@ -1,105 +1,88 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
Logger,
|
||||
Headers,
|
||||
HttpException,
|
||||
} from '@nestjs/common';
|
||||
import { Controller, Post, Body, Logger, Headers, HttpException } from '@nestjs/common';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { AiEnrichmentService } from '../ai/ai-enrichment.service';
|
||||
|
||||
@Controller('api/call')
|
||||
export class CallLookupController {
|
||||
private readonly logger = new Logger(CallLookupController.name);
|
||||
private readonly logger = new Logger(CallLookupController.name);
|
||||
|
||||
constructor(
|
||||
private readonly platform: PlatformGraphqlService,
|
||||
private readonly ai: AiEnrichmentService,
|
||||
) {}
|
||||
constructor(
|
||||
private readonly platform: PlatformGraphqlService,
|
||||
private readonly ai: AiEnrichmentService,
|
||||
) {}
|
||||
|
||||
@Post('lookup')
|
||||
async lookupCaller(
|
||||
@Body() body: { phoneNumber: string },
|
||||
@Headers('authorization') authHeader: string,
|
||||
) {
|
||||
if (!authHeader) throw new HttpException('Authorization required', 401);
|
||||
if (!body.phoneNumber) throw new HttpException('phoneNumber required', 400);
|
||||
@Post('lookup')
|
||||
async lookupCaller(
|
||||
@Body() body: { phoneNumber: string },
|
||||
@Headers('authorization') authHeader: string,
|
||||
) {
|
||||
if (!authHeader) throw new HttpException('Authorization required', 401);
|
||||
if (!body.phoneNumber) throw new HttpException('phoneNumber required', 400);
|
||||
|
||||
const phone = body.phoneNumber.replace(/^0+/, '');
|
||||
this.logger.log(`Looking up caller: ${phone}`);
|
||||
const phone = body.phoneNumber.replace(/^0+/, '');
|
||||
this.logger.log(`Looking up caller: ${phone}`);
|
||||
|
||||
// Query platform for leads matching this phone number
|
||||
let lead = null;
|
||||
let activities: any[] = [];
|
||||
// Query platform for leads matching this phone number
|
||||
let lead = null;
|
||||
let activities: any[] = [];
|
||||
|
||||
try {
|
||||
lead = await this.platform.findLeadByPhoneWithToken(phone, authHeader);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Lead lookup failed: ${err}`);
|
||||
}
|
||||
|
||||
if (lead) {
|
||||
this.logger.log(
|
||||
`Matched lead: ${lead.id} — ${lead.contactName?.firstName} ${lead.contactName?.lastName}`,
|
||||
);
|
||||
|
||||
// Get recent activities
|
||||
try {
|
||||
activities = await this.platform.getLeadActivitiesWithToken(
|
||||
lead.id,
|
||||
authHeader,
|
||||
5,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Activity fetch failed: ${err}`);
|
||||
}
|
||||
|
||||
// AI enrichment if no existing summary
|
||||
if (!lead.aiSummary) {
|
||||
try {
|
||||
const enrichment = await this.ai.enrichLead({
|
||||
firstName: lead.contactName?.firstName,
|
||||
lastName: lead.contactName?.lastName,
|
||||
leadSource: lead.leadSource ?? undefined,
|
||||
interestedService: lead.interestedService ?? undefined,
|
||||
leadStatus: lead.leadStatus ?? undefined,
|
||||
contactAttempts: lead.contactAttempts ?? undefined,
|
||||
createdAt: lead.createdAt,
|
||||
activities: activities.map((a: any) => ({
|
||||
activityType: a.activityType ?? '',
|
||||
summary: a.summary ?? '',
|
||||
})),
|
||||
});
|
||||
|
||||
lead.aiSummary = enrichment.aiSummary;
|
||||
lead.aiSuggestedAction = enrichment.aiSuggestedAction;
|
||||
|
||||
// Persist AI enrichment back to platform
|
||||
try {
|
||||
await this.platform.updateLeadWithToken(
|
||||
lead.id,
|
||||
{
|
||||
aiSummary: enrichment.aiSummary,
|
||||
aiSuggestedAction: enrichment.aiSuggestedAction,
|
||||
},
|
||||
authHeader,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to persist AI enrichment: ${err}`);
|
||||
}
|
||||
lead = await this.platform.findLeadByPhoneWithToken(phone, authHeader);
|
||||
} catch (err) {
|
||||
this.logger.warn(`AI enrichment failed: ${err}`);
|
||||
this.logger.warn(`Lead lookup failed: ${err}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.logger.log(`No lead found for phone ${phone}`);
|
||||
}
|
||||
|
||||
return {
|
||||
lead,
|
||||
activities,
|
||||
matched: lead !== null,
|
||||
};
|
||||
}
|
||||
if (lead) {
|
||||
this.logger.log(`Matched lead: ${lead.id} — ${lead.contactName?.firstName} ${lead.contactName?.lastName}`);
|
||||
|
||||
// Get recent activities
|
||||
try {
|
||||
activities = await this.platform.getLeadActivitiesWithToken(lead.id, authHeader, 5);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Activity fetch failed: ${err}`);
|
||||
}
|
||||
|
||||
// AI enrichment if no existing summary
|
||||
if (!lead.aiSummary) {
|
||||
try {
|
||||
const enrichment = await this.ai.enrichLead({
|
||||
firstName: lead.contactName?.firstName,
|
||||
lastName: lead.contactName?.lastName,
|
||||
leadSource: lead.leadSource ?? undefined,
|
||||
interestedService: lead.interestedService ?? undefined,
|
||||
leadStatus: lead.leadStatus ?? undefined,
|
||||
contactAttempts: lead.contactAttempts ?? undefined,
|
||||
createdAt: lead.createdAt,
|
||||
activities: activities.map((a: any) => ({
|
||||
activityType: a.activityType ?? '',
|
||||
summary: a.summary ?? '',
|
||||
})),
|
||||
});
|
||||
|
||||
lead.aiSummary = enrichment.aiSummary;
|
||||
lead.aiSuggestedAction = enrichment.aiSuggestedAction;
|
||||
|
||||
// Persist AI enrichment back to platform
|
||||
try {
|
||||
await this.platform.updateLeadWithToken(lead.id, {
|
||||
aiSummary: enrichment.aiSummary,
|
||||
aiSuggestedAction: enrichment.aiSuggestedAction,
|
||||
}, authHeader);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to persist AI enrichment: ${err}`);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`AI enrichment failed: ${err}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.logger.log(`No lead found for phone ${phone}`);
|
||||
}
|
||||
|
||||
return {
|
||||
lead,
|
||||
activities,
|
||||
matched: lead !== null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
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 { AuthModule } from '../auth/auth.module';
|
||||
import { CallerResolutionController } from './caller-resolution.controller';
|
||||
import { CallerResolutionService } from './caller-resolution.service';
|
||||
|
||||
@Module({
|
||||
imports: [PlatformModule, AuthModule],
|
||||
imports: [PlatformModule, forwardRef(() => AuthModule)],
|
||||
controllers: [CallerResolutionController],
|
||||
providers: [CallerResolutionService],
|
||||
exports: [CallerResolutionService],
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { SessionService } from '../auth/session.service';
|
||||
|
||||
const CACHE_TTL = 3600; // 1 hour
|
||||
const CACHE_PREFIX = 'caller:';
|
||||
|
||||
export type ResolvedCaller = {
|
||||
leadId: string;
|
||||
@@ -11,7 +7,7 @@ export type ResolvedCaller = {
|
||||
firstName: string;
|
||||
lastName: 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()
|
||||
@@ -20,28 +16,24 @@ export class CallerResolutionService {
|
||||
|
||||
constructor(
|
||||
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> {
|
||||
const normalized = phone.replace(/\D/g, '').slice(-10);
|
||||
if (normalized.length < 10) {
|
||||
throw new Error(`Invalid phone number: ${phone}`);
|
||||
}
|
||||
|
||||
// 1. Check cache
|
||||
const cached = await this.cache.getCache(`${CACHE_PREFIX}${normalized}`);
|
||||
if (cached) {
|
||||
this.logger.log(`[RESOLVE] Cache hit for ${normalized}`);
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
|
||||
// 2. Look up lead by phone
|
||||
const lead = await this.findLeadByPhone(normalized, auth);
|
||||
|
||||
// 3. Look up patient by phone
|
||||
const patient = await this.findPatientByPhone(normalized, auth);
|
||||
// Lookup lead + patient by phone, in parallel.
|
||||
const [lead, patient] = await Promise.all([
|
||||
this.findLeadByPhone(normalized, auth),
|
||||
this.findPatientByPhone(normalized, auth),
|
||||
]);
|
||||
|
||||
let result: ResolvedCaller;
|
||||
|
||||
@@ -51,6 +43,11 @@ export class CallerResolutionService {
|
||||
await this.linkLeadToPatient(lead.id, patient.id, auth);
|
||||
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 = {
|
||||
leadId: lead.id,
|
||||
patientId: patient.id,
|
||||
@@ -76,6 +73,9 @@ export class CallerResolutionService {
|
||||
// Patient exists, no lead — create lead
|
||||
const newLead = await this.createLead(patient.firstName, patient.lastName, normalized, patient.id, auth);
|
||||
this.logger.log(`[RESOLVE] Created lead ${newLead.id} for existing patient ${patient.id}`);
|
||||
if (patient.patientType === 'NEW') {
|
||||
this.upgradeToReturning(patient.id, auth);
|
||||
}
|
||||
result = {
|
||||
leadId: newLead.id,
|
||||
patientId: patient.id,
|
||||
@@ -85,13 +85,18 @@ export class CallerResolutionService {
|
||||
isNew: false,
|
||||
};
|
||||
} else {
|
||||
// Neither exists — create both
|
||||
const newPatient = await this.createPatient('', '', normalized, auth);
|
||||
const newLead = await this.createLead('', '', normalized, newPatient.id, auth);
|
||||
this.logger.log(`[RESOLVE] Created new lead ${newLead.id} + patient ${newPatient.id} for ${normalized}`);
|
||||
// Neither exists — return empty IDs with isNew=true. Caller
|
||||
// code is responsible for creating records with the real name
|
||||
// they've collected (enquiry form, appointment form, widget,
|
||||
// AI tools). This avoids the "Unknown" placeholder cascade:
|
||||
// no Lead/Patient is ever written unless we have a real name
|
||||
// to attach to it. Missed-call / poller paths that have no
|
||||
// name persist the Call record with leadName=phone as the
|
||||
// honest snapshot.
|
||||
this.logger.log(`[RESOLVE] No existing records for ${normalized} — returning isNew=true`);
|
||||
result = {
|
||||
leadId: newLead.id,
|
||||
patientId: newPatient.id,
|
||||
leadId: '',
|
||||
patientId: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
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;
|
||||
}
|
||||
|
||||
// Invalidate cache for a phone number (call after updates)
|
||||
async invalidate(phone: string): Promise<void> {
|
||||
const normalized = phone.replace(/\D/g, '').slice(-10);
|
||||
await this.cache.setCache(`${CACHE_PREFIX}${normalized}`, '', 1); // expire immediately
|
||||
}
|
||||
|
||||
// Indexed lookup — platform filters by phone server-side. Matches on
|
||||
// the last 10 digits regardless of stored format (+919XXXX / 91XXXX /
|
||||
// XXXX / +91-XXXX), via the `like: "%XXXXXXXXXX"` predicate.
|
||||
private async findLeadByPhone(phone10: string, auth: string): Promise<{ id: string; firstName: string; lastName: string; patientId: string | null } | null> {
|
||||
try {
|
||||
const data = await this.platform.queryWithAuth<{ leads: { edges: { node: any }[] } }>(
|
||||
`{ leads(first: 200) { edges { node {
|
||||
`{ leads(first: 1, filter: { contactPhone: { primaryPhoneNumber: { like: "%${phone10}" } } }) { edges { node {
|
||||
id
|
||||
contactName { firstName lastName }
|
||||
contactPhone { primaryPhoneNumber }
|
||||
patientId
|
||||
} } } }`,
|
||||
undefined,
|
||||
auth,
|
||||
);
|
||||
|
||||
const match = data.leads.edges.find(e => {
|
||||
const num = (e.node.contactPhone?.primaryPhoneNumber ?? '').replace(/\D/g, '').slice(-10);
|
||||
return num.length >= 10 && num === phone10;
|
||||
});
|
||||
|
||||
const match = data.leads.edges[0]?.node;
|
||||
if (!match) return null;
|
||||
|
||||
return {
|
||||
id: match.node.id,
|
||||
firstName: match.node.contactName?.firstName ?? '',
|
||||
lastName: match.node.contactName?.lastName ?? '',
|
||||
patientId: match.node.patientId || null,
|
||||
id: match.id,
|
||||
firstName: match.contactName?.firstName ?? '',
|
||||
lastName: match.contactName?.lastName ?? '',
|
||||
patientId: match.patientId || null,
|
||||
};
|
||||
} catch (err: any) {
|
||||
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 {
|
||||
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
|
||||
fullName { firstName lastName }
|
||||
phones { primaryPhoneNumber }
|
||||
patientType
|
||||
} } } }`,
|
||||
undefined,
|
||||
auth,
|
||||
);
|
||||
|
||||
const match = data.patients.edges.find(e => {
|
||||
const num = (e.node.phones?.primaryPhoneNumber ?? '').replace(/\D/g, '').slice(-10);
|
||||
return num.length >= 10 && num === phone10;
|
||||
});
|
||||
|
||||
const match = data.patients.edges[0]?.node;
|
||||
if (!match) return null;
|
||||
|
||||
return {
|
||||
id: match.node.id,
|
||||
firstName: match.node.fullName?.firstName ?? '',
|
||||
lastName: match.node.fullName?.lastName ?? '',
|
||||
id: match.id,
|
||||
firstName: match.fullName?.firstName ?? '',
|
||||
lastName: match.fullName?.lastName ?? '',
|
||||
patientType: match.patientType ?? null,
|
||||
};
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`[RESOLVE] Patient lookup failed: ${err.message}`);
|
||||
@@ -178,6 +165,7 @@ export class CallerResolutionService {
|
||||
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||
{
|
||||
data: {
|
||||
name: `${firstName || 'Unknown'} ${lastName || ''}`.trim(),
|
||||
fullName: { firstName: firstName || 'Unknown', lastName: lastName || '' },
|
||||
phones: { primaryPhoneNumber: `+91${phone}` },
|
||||
patientType: 'NEW',
|
||||
@@ -206,6 +194,19 @@ export class CallerResolutionService {
|
||||
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> {
|
||||
await this.platform.queryWithAuth<any>(
|
||||
`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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
286
src/config/ai.defaults.ts
Normal file
286
src/config/ai.defaults.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
// 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.
|
||||
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):
|
||||
{{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 { 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({
|
||||
controllers: [ThemeController],
|
||||
providers: [ThemeService],
|
||||
exports: [ThemeService],
|
||||
imports: [AuthModule, PlatformModule],
|
||||
controllers: [
|
||||
ThemeController,
|
||||
WidgetConfigController,
|
||||
SetupStateController,
|
||||
TelephonyConfigController,
|
||||
AiConfigController,
|
||||
],
|
||||
providers: [
|
||||
ThemeService,
|
||||
WidgetKeysService,
|
||||
WidgetConfigService,
|
||||
SetupStateService,
|
||||
TelephonyConfigService,
|
||||
AiConfigService,
|
||||
],
|
||||
exports: [
|
||||
ThemeService,
|
||||
WidgetKeysService,
|
||||
WidgetConfigService,
|
||||
SetupStateService,
|
||||
TelephonyConfigService,
|
||||
AiConfigService,
|
||||
],
|
||||
})
|
||||
export class ConfigThemeModule {}
|
||||
|
||||
@@ -1,38 +1,41 @@
|
||||
export default () => ({
|
||||
port: parseInt(process.env.PORT ?? '4100', 10),
|
||||
corsOrigins: (process.env.CORS_ORIGINS ?? 'http://localhost:5173')
|
||||
.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter((origin) => origin.length > 0),
|
||||
platform: {
|
||||
graphqlUrl:
|
||||
process.env.PLATFORM_GRAPHQL_URL ?? 'http://localhost:4000/graphql',
|
||||
apiKey: process.env.PLATFORM_API_KEY ?? '',
|
||||
},
|
||||
exotel: {
|
||||
apiKey: process.env.EXOTEL_API_KEY ?? '',
|
||||
apiToken: process.env.EXOTEL_API_TOKEN ?? '',
|
||||
accountSid: process.env.EXOTEL_ACCOUNT_SID ?? '',
|
||||
subdomain: process.env.EXOTEL_SUBDOMAIN ?? 'api.exotel.com',
|
||||
webhookSecret: process.env.EXOTEL_WEBHOOK_SECRET ?? '',
|
||||
},
|
||||
redis: {
|
||||
url: process.env.REDIS_URL ?? 'redis://localhost:6379',
|
||||
},
|
||||
sip: {
|
||||
domain: process.env.SIP_DOMAIN ?? 'blr-pub-rtc4.ozonetel.com',
|
||||
wsPort: process.env.SIP_WS_PORT ?? '444',
|
||||
},
|
||||
missedQueue: {
|
||||
pollIntervalMs: parseInt(
|
||||
process.env.MISSED_QUEUE_POLL_INTERVAL_MS ?? '30000',
|
||||
10,
|
||||
),
|
||||
},
|
||||
ai: {
|
||||
provider: process.env.AI_PROVIDER ?? 'openai',
|
||||
anthropicApiKey: process.env.ANTHROPIC_API_KEY ?? '',
|
||||
openaiApiKey: process.env.OPENAI_API_KEY ?? '',
|
||||
model: process.env.AI_MODEL ?? 'gpt-4o-mini',
|
||||
},
|
||||
port: parseInt(process.env.PORT ?? '4100', 10),
|
||||
corsOrigin: process.env.CORS_ORIGIN ?? 'http://localhost:5173',
|
||||
platform: {
|
||||
graphqlUrl: process.env.PLATFORM_GRAPHQL_URL ?? 'http://localhost:4000/graphql',
|
||||
apiKey: process.env.PLATFORM_API_KEY ?? '',
|
||||
},
|
||||
exotel: {
|
||||
apiKey: process.env.EXOTEL_API_KEY ?? '',
|
||||
apiToken: process.env.EXOTEL_API_TOKEN ?? '',
|
||||
accountSid: process.env.EXOTEL_ACCOUNT_SID ?? '',
|
||||
subdomain: process.env.EXOTEL_SUBDOMAIN ?? 'api.exotel.com',
|
||||
webhookSecret: process.env.EXOTEL_WEBHOOK_SECRET ?? '',
|
||||
},
|
||||
redis: {
|
||||
url: process.env.REDIS_URL ?? 'redis://localhost:6379',
|
||||
},
|
||||
sip: {
|
||||
domain: process.env.SIP_DOMAIN ?? 'blr-pub-rtc4.ozonetel.com',
|
||||
wsPort: process.env.SIP_WS_PORT ?? '444',
|
||||
},
|
||||
missedQueue: {
|
||||
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: {
|
||||
provider: process.env.AI_PROVIDER ?? 'openai',
|
||||
anthropicApiKey: process.env.ANTHROPIC_API_KEY ?? '',
|
||||
openaiApiKey: process.env.OPENAI_API_KEY ?? '',
|
||||
model: process.env.AI_MODEL ?? 'gpt-4o-mini',
|
||||
},
|
||||
});
|
||||
|
||||
56
src/config/setup-state.controller.ts
Normal file
56
src/config/setup-state.controller.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
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() };
|
||||
}
|
||||
}
|
||||
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,
|
||||
},
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PlatformModule } from '../platform/platform.module';
|
||||
import { LeadEmbedController } from './lead-embed.controller';
|
||||
|
||||
@Module({
|
||||
imports: [PlatformModule],
|
||||
controllers: [LeadEmbedController],
|
||||
})
|
||||
export class EmbedModule {}
|
||||
@@ -1,193 +0,0 @@
|
||||
import { Controller, Post, Body, Logger, HttpException } from '@nestjs/common';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Controller('embed/leads')
|
||||
export class LeadEmbedController {
|
||||
private readonly logger = new Logger(LeadEmbedController.name);
|
||||
private readonly apiKey: string;
|
||||
|
||||
constructor(
|
||||
private readonly platform: PlatformGraphqlService,
|
||||
private readonly config: ConfigService,
|
||||
) {
|
||||
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||
}
|
||||
|
||||
@Post('create')
|
||||
async handleLeadCreation(@Body() body: Record<string, any>) {
|
||||
console.log('Lead creation from embed received:', body);
|
||||
this.logger.log(
|
||||
`Lead creation from embed received: ${JSON.stringify(body)}`,
|
||||
);
|
||||
|
||||
const authHeader = this.apiKey ? `Bearer ${this.apiKey}` : '';
|
||||
if (!authHeader) {
|
||||
this.logger.warn('No PLATFORM_API_KEY configured — cannot create lead');
|
||||
throw new HttpException('Server configuration error', 500);
|
||||
}
|
||||
|
||||
try {
|
||||
const leadData = this.mapIncomingDataToLead(body);
|
||||
|
||||
if (!leadData.contactPhone && !leadData.contactEmail) {
|
||||
throw new HttpException(
|
||||
'Either contact phone or email is required',
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.platform.queryWithAuth<any>(
|
||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||
{ data: leadData },
|
||||
authHeader,
|
||||
);
|
||||
|
||||
const leadId = result.createLead.id;
|
||||
this.logger.log(`Lead created successfully: ${leadId}`);
|
||||
|
||||
if (body.notes || body.type) {
|
||||
await this.createInitialActivity(leadId, body, authHeader);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
leadId,
|
||||
message: 'Lead created successfully',
|
||||
};
|
||||
} catch (error: any) {
|
||||
const responseData = error?.response?.data
|
||||
? JSON.stringify(error.response.data)
|
||||
: '';
|
||||
this.logger.error(
|
||||
`Lead creation failed: ${error.message} ${responseData}`,
|
||||
);
|
||||
|
||||
if (error instanceof HttpException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new HttpException(
|
||||
error.message || 'Lead creation failed',
|
||||
error.response?.status || 500,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private mapIncomingDataToLead(
|
||||
body: Record<string, any>,
|
||||
): Record<string, any> {
|
||||
const leadData: Record<string, any> = {};
|
||||
|
||||
const contactName = body.contact_name || body.contactName || 'Unknown';
|
||||
const nameParts = contactName.split(' ');
|
||||
const firstName = nameParts[0] || 'Unknown';
|
||||
const lastName = nameParts.slice(1).join(' ');
|
||||
|
||||
leadData.name = contactName;
|
||||
leadData.contactName = {
|
||||
firstName,
|
||||
lastName: lastName || undefined,
|
||||
};
|
||||
|
||||
if (body.contact_phone || body.contactPhone) {
|
||||
const phone = body.contact_phone || body.contactPhone;
|
||||
const cleanPhone = phone.replace(/\D/g, '');
|
||||
leadData.contactPhone = {
|
||||
primaryPhoneNumber: cleanPhone.startsWith('91')
|
||||
? `+${cleanPhone}`
|
||||
: `+91${cleanPhone}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (body.contact_email || body.contactEmail) {
|
||||
leadData.contactEmail = {
|
||||
primaryEmail: body.contact_email || body.contactEmail,
|
||||
};
|
||||
}
|
||||
|
||||
leadData.source = body.source || 'WEBSITE';
|
||||
leadData.status = body.lead_status || body.status || 'NEW';
|
||||
|
||||
const interestedService = this.mapInterestedService(body);
|
||||
if (interestedService) {
|
||||
leadData.interestedService = interestedService;
|
||||
}
|
||||
|
||||
if (body.assigned_agent || body.assignedAgent) {
|
||||
leadData.assignedAgent = body.assigned_agent || body.assignedAgent;
|
||||
}
|
||||
|
||||
if (body.campaign_id || body.campaignId) {
|
||||
leadData.campaignId = body.campaign_id || body.campaignId;
|
||||
}
|
||||
|
||||
return leadData;
|
||||
}
|
||||
|
||||
private mapInterestedService(body: Record<string, any>): string | null {
|
||||
const type = body.type || body.interested_service || body.interestedService;
|
||||
|
||||
if (!type) {
|
||||
return body.department || null;
|
||||
}
|
||||
|
||||
const serviceMap: Record<string, string> = {
|
||||
consultation: 'Appointment',
|
||||
follow_up: 'Appointment',
|
||||
procedure: 'Appointment',
|
||||
emergency: 'Appointment',
|
||||
general_enquiry: 'General Enquiry',
|
||||
general: 'General Enquiry',
|
||||
};
|
||||
|
||||
return serviceMap[type.toLowerCase()] || type;
|
||||
}
|
||||
|
||||
private async createInitialActivity(
|
||||
leadId: string,
|
||||
body: Record<string, any>,
|
||||
authHeader: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const activityType =
|
||||
body.type === 'consultation' || body.type === 'appointment'
|
||||
? 'APPOINTMENT_BOOKED'
|
||||
: 'CALL_RECEIVED';
|
||||
|
||||
let summary = 'Lead submitted via web form';
|
||||
if (body.type) {
|
||||
summary = `${body.type.replace(/_/g, ' ')} requested`;
|
||||
}
|
||||
if (body.department) {
|
||||
summary += ` - ${body.department}`;
|
||||
}
|
||||
if (body.title) {
|
||||
summary += ` (from ${body.title})`;
|
||||
}
|
||||
|
||||
await this.platform.queryWithAuth<any>(
|
||||
`mutation($data: LeadActivityCreateInput!) { createLeadActivity(data: $data) { id } }`,
|
||||
{
|
||||
data: {
|
||||
name: summary.substring(0, 80),
|
||||
activityType,
|
||||
summary,
|
||||
occurredAt: new Date().toISOString(),
|
||||
performedBy: 'System',
|
||||
channel: 'PHONE',
|
||||
leadId,
|
||||
},
|
||||
},
|
||||
authHeader,
|
||||
);
|
||||
|
||||
this.logger.log(`Initial activity created for lead ${leadId}`);
|
||||
} catch (error: any) {
|
||||
const errorDetails = error?.response?.data
|
||||
? JSON.stringify(error.response.data)
|
||||
: error.message;
|
||||
this.logger.error(`Failed to create initial activity: ${errorDetails}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type { CallCompletedEvent } from '../event-types';
|
||||
import { PlatformGraphqlService } from '../../platform/platform-graphql.service';
|
||||
import { createAiModel } from '../../ai/ai-provider';
|
||||
import type { LanguageModel } from 'ai';
|
||||
import { AiConfigService } from '../../config/ai-config.service';
|
||||
|
||||
@Injectable()
|
||||
export class AiInsightConsumer implements OnModuleInit {
|
||||
@@ -18,8 +19,15 @@ export class AiInsightConsumer implements OnModuleInit {
|
||||
private eventBus: EventBusService,
|
||||
private platform: PlatformGraphqlService,
|
||||
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() {
|
||||
@@ -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'),
|
||||
suggestedAction: z.string().describe('One clear next action for the agent'),
|
||||
}),
|
||||
system: `You are a CRM assistant for Global Hospital Bangalore.
|
||||
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.`,
|
||||
system: this.aiConfig.renderPrompt('callInsight', {
|
||||
hospitalName: process.env.HOSPITAL_NAME ?? 'the hospital',
|
||||
}),
|
||||
prompt: `Lead: ${leadName}
|
||||
Status: ${lead.status ?? 'Unknown'}
|
||||
Source: ${lead.source ?? 'Unknown'}
|
||||
|
||||
@@ -5,28 +5,26 @@ import type { ExotelWebhookPayload } from './exotel.types';
|
||||
|
||||
@Controller('webhooks/exotel')
|
||||
export class ExotelController {
|
||||
private readonly logger = new Logger(ExotelController.name);
|
||||
private readonly logger = new Logger(ExotelController.name);
|
||||
|
||||
constructor(
|
||||
private readonly exotelService: ExotelService,
|
||||
private readonly callEventsService: CallEventsService,
|
||||
) {}
|
||||
constructor(
|
||||
private readonly exotelService: ExotelService,
|
||||
private readonly callEventsService: CallEventsService,
|
||||
) {}
|
||||
|
||||
@Post('call-status')
|
||||
@HttpCode(200)
|
||||
async handleCallStatus(@Body() payload: ExotelWebhookPayload) {
|
||||
this.logger.log(
|
||||
`Received Exotel webhook: ${payload.event_details?.event_type}`,
|
||||
);
|
||||
@Post('call-status')
|
||||
@HttpCode(200)
|
||||
async handleCallStatus(@Body() payload: ExotelWebhookPayload) {
|
||||
this.logger.log(`Received Exotel webhook: ${payload.event_details?.event_type}`);
|
||||
|
||||
const callEvent = this.exotelService.parseWebhook(payload);
|
||||
const callEvent = this.exotelService.parseWebhook(payload);
|
||||
|
||||
if (callEvent.eventType === 'answered') {
|
||||
await this.callEventsService.handleIncomingCall(callEvent);
|
||||
} else if (callEvent.eventType === 'ended') {
|
||||
await this.callEventsService.handleCallEnded(callEvent);
|
||||
if (callEvent.eventType === 'answered') {
|
||||
await this.callEventsService.handleIncomingCall(callEvent);
|
||||
} else if (callEvent.eventType === 'ended') {
|
||||
await this.callEventsService.handleCallEnded(callEvent);
|
||||
}
|
||||
|
||||
return { status: 'received' };
|
||||
}
|
||||
|
||||
return { status: 'received' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ import { ExotelController } from './exotel.controller';
|
||||
import { ExotelService } from './exotel.service';
|
||||
|
||||
@Module({
|
||||
imports: [CallEventsModule],
|
||||
controllers: [ExotelController],
|
||||
providers: [ExotelService],
|
||||
exports: [ExotelService],
|
||||
imports: [CallEventsModule],
|
||||
controllers: [ExotelController],
|
||||
providers: [ExotelService],
|
||||
exports: [ExotelService],
|
||||
})
|
||||
export class ExotelModule {}
|
||||
|
||||
@@ -3,34 +3,29 @@ import type { ExotelWebhookPayload, CallEvent } from './exotel.types';
|
||||
|
||||
@Injectable()
|
||||
export class ExotelService {
|
||||
private readonly logger = new Logger(ExotelService.name);
|
||||
private readonly logger = new Logger(ExotelService.name);
|
||||
|
||||
parseWebhook(payload: ExotelWebhookPayload): CallEvent {
|
||||
const { event_details, call_details } = payload;
|
||||
parseWebhook(payload: ExotelWebhookPayload): CallEvent {
|
||||
const { event_details, call_details } = payload;
|
||||
|
||||
const eventType =
|
||||
event_details.event_type === 'answered'
|
||||
? 'answered'
|
||||
: event_details.event_type === 'terminal'
|
||||
? 'ended'
|
||||
: 'ringing';
|
||||
const eventType = event_details.event_type === 'answered' ? 'answered'
|
||||
: event_details.event_type === 'terminal' ? 'ended'
|
||||
: 'ringing';
|
||||
|
||||
const callEvent: CallEvent = {
|
||||
exotelCallSid: call_details.call_sid,
|
||||
eventType,
|
||||
direction: call_details.direction,
|
||||
callerPhone: call_details.customer_details?.number ?? '',
|
||||
agentName: call_details.assigned_agent_details?.name ?? 'Unknown',
|
||||
agentPhone: call_details.assigned_agent_details?.number ?? '',
|
||||
duration: call_details.total_talk_time,
|
||||
recordingUrl: call_details.recordings?.[0]?.url,
|
||||
callStatus: call_details.call_status,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
const callEvent: CallEvent = {
|
||||
exotelCallSid: call_details.call_sid,
|
||||
eventType,
|
||||
direction: call_details.direction,
|
||||
callerPhone: call_details.customer_details?.number ?? '',
|
||||
agentName: call_details.assigned_agent_details?.name ?? 'Unknown',
|
||||
agentPhone: call_details.assigned_agent_details?.number ?? '',
|
||||
duration: call_details.total_talk_time,
|
||||
recordingUrl: call_details.recordings?.[0]?.url,
|
||||
callStatus: call_details.call_status,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.logger.log(
|
||||
`Parsed Exotel event: ${eventType} for call ${call_details.call_sid}`,
|
||||
);
|
||||
return callEvent;
|
||||
}
|
||||
this.logger.log(`Parsed Exotel event: ${eventType} for call ${call_details.call_sid}`);
|
||||
return callEvent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
// Exotel webhook payload (from their API docs)
|
||||
export type ExotelWebhookPayload = {
|
||||
event_details: {
|
||||
event_type: 'answered' | 'terminal';
|
||||
};
|
||||
call_details: {
|
||||
call_sid: string;
|
||||
direction: 'inbound' | 'outbound';
|
||||
call_status?: string;
|
||||
total_talk_time?: number;
|
||||
assigned_agent_details?: {
|
||||
name: string;
|
||||
number: string;
|
||||
event_details: {
|
||||
event_type: 'answered' | 'terminal';
|
||||
};
|
||||
customer_details?: {
|
||||
number: string;
|
||||
name?: string;
|
||||
call_details: {
|
||||
call_sid: string;
|
||||
direction: 'inbound' | 'outbound';
|
||||
call_status?: string;
|
||||
total_talk_time?: number;
|
||||
assigned_agent_details?: {
|
||||
name: string;
|
||||
number: string;
|
||||
};
|
||||
customer_details?: {
|
||||
number: string;
|
||||
name?: string;
|
||||
};
|
||||
recordings?: { url: string }[];
|
||||
};
|
||||
recordings?: { url: string }[];
|
||||
};
|
||||
};
|
||||
|
||||
// Internal call event (normalized)
|
||||
export type CallEvent = {
|
||||
exotelCallSid: string;
|
||||
eventType: 'ringing' | 'answered' | 'ended';
|
||||
direction: 'inbound' | 'outbound';
|
||||
callerPhone: string;
|
||||
agentName: string;
|
||||
agentPhone: string;
|
||||
duration?: number;
|
||||
recordingUrl?: string;
|
||||
callStatus?: string;
|
||||
timestamp: string;
|
||||
exotelCallSid: string;
|
||||
eventType: 'ringing' | 'answered' | 'ended';
|
||||
direction: 'inbound' | 'outbound';
|
||||
callerPhone: string;
|
||||
agentName: string;
|
||||
agentPhone: string;
|
||||
duration?: number;
|
||||
recordingUrl?: string;
|
||||
callStatus?: string;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
@@ -1,48 +1,45 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
Logger,
|
||||
HttpException,
|
||||
} from '@nestjs/common';
|
||||
import { Controller, Post, Req, Res, Logger, HttpException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { Request, Response } from 'express';
|
||||
import axios from 'axios';
|
||||
|
||||
@Controller('graphql')
|
||||
export class GraphqlProxyController {
|
||||
private readonly logger = new Logger(GraphqlProxyController.name);
|
||||
private readonly graphqlUrl: string;
|
||||
private readonly logger = new Logger(GraphqlProxyController.name);
|
||||
private readonly graphqlUrl: string;
|
||||
|
||||
constructor(private config: ConfigService) {
|
||||
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
|
||||
}
|
||||
|
||||
@Post()
|
||||
async proxy(@Req() req: Request, @Res() res: Response) {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader) {
|
||||
throw new HttpException('Authorization header required', 401);
|
||||
constructor(private config: ConfigService) {
|
||||
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post(this.graphqlUrl, req.body, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: authHeader,
|
||||
},
|
||||
});
|
||||
@Post()
|
||||
async proxy(@Req() req: Request, @Res() res: Response) {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
res.status(response.status).json(response.data);
|
||||
} catch (error: any) {
|
||||
if (error.response) {
|
||||
res.status(error.response.status).json(error.response.data);
|
||||
} else {
|
||||
this.logger.error(`GraphQL proxy error: ${error.message}`);
|
||||
throw new HttpException('Platform unreachable', 503);
|
||||
}
|
||||
if (!authHeader) {
|
||||
throw new HttpException('Authorization header required', 401);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
this.graphqlUrl,
|
||||
req.body,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': authHeader,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
res.status(response.status).json(response.data);
|
||||
} catch (error: any) {
|
||||
if (error.response) {
|
||||
res.status(error.response.status).json(error.response.data);
|
||||
} else {
|
||||
this.logger.error(`GraphQL proxy error: ${error.message}`);
|
||||
throw new HttpException('Platform unreachable', 503);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,6 @@ import { Module } from '@nestjs/common';
|
||||
import { GraphqlProxyController } from './graphql-proxy.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [GraphqlProxyController],
|
||||
controllers: [GraphqlProxyController],
|
||||
})
|
||||
export class GraphqlProxyModule {}
|
||||
|
||||
@@ -4,39 +4,35 @@ import axios from 'axios';
|
||||
|
||||
@Controller('api/health')
|
||||
export class HealthController {
|
||||
private readonly logger = new Logger(HealthController.name);
|
||||
private readonly graphqlUrl: string;
|
||||
private readonly logger = new Logger(HealthController.name);
|
||||
private readonly graphqlUrl: string;
|
||||
|
||||
constructor(private config: ConfigService) {
|
||||
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
|
||||
}
|
||||
|
||||
@Get()
|
||||
async check() {
|
||||
let platformReachable = false;
|
||||
let platformLatency = 0;
|
||||
|
||||
try {
|
||||
const start = Date.now();
|
||||
await axios.post(
|
||||
this.graphqlUrl,
|
||||
{ query: '{ __typename }' },
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
timeout: 5000,
|
||||
},
|
||||
);
|
||||
platformLatency = Date.now() - start;
|
||||
platformReachable = true;
|
||||
} catch {
|
||||
platformReachable = false;
|
||||
constructor(private config: ConfigService) {
|
||||
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
|
||||
}
|
||||
|
||||
return {
|
||||
status: platformReachable ? 'ok' : 'degraded',
|
||||
platform: { reachable: platformReachable, latencyMs: platformLatency },
|
||||
ozonetel: { configured: !!process.env.EXOTEL_API_KEY },
|
||||
ai: { configured: !!process.env.ANTHROPIC_API_KEY },
|
||||
};
|
||||
}
|
||||
@Get()
|
||||
async check() {
|
||||
let platformReachable = false;
|
||||
let platformLatency = 0;
|
||||
|
||||
try {
|
||||
const start = Date.now();
|
||||
await axios.post(this.graphqlUrl, { query: '{ __typename }' }, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
timeout: 5000,
|
||||
});
|
||||
platformLatency = Date.now() - start;
|
||||
platformReachable = true;
|
||||
} catch {
|
||||
platformReachable = false;
|
||||
}
|
||||
|
||||
return {
|
||||
status: platformReachable ? 'ok' : 'degraded',
|
||||
platform: { reachable: platformReachable, latencyMs: platformLatency },
|
||||
ozonetel: { configured: !!process.env.EXOTEL_API_KEY },
|
||||
ai: { configured: !!process.env.ANTHROPIC_API_KEY },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,6 @@ import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
|
||||
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
|
||||
let hospitalContext = {
|
||||
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',
|
||||
department,
|
||||
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);
|
||||
await gql(
|
||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||
{
|
||||
data: {
|
||||
name: `AI — ${patientName}`,
|
||||
contactName: {
|
||||
firstName: patientName.split(' ')[0],
|
||||
lastName: patientName.split(' ').slice(1).join(' ') || '',
|
||||
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(
|
||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||
{
|
||||
data: {
|
||||
name: `AI — ${patientName}`,
|
||||
contactName: { firstName: fn, lastName: ln },
|
||||
contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` },
|
||||
source: 'PHONE',
|
||||
status: 'APPOINTMENT_SET',
|
||||
interestedService: department,
|
||||
...(newPatientId ? { patientId: newPatientId } : {}),
|
||||
},
|
||||
contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` },
|
||||
source: 'PHONE',
|
||||
status: 'APPOINTMENT_SET',
|
||||
interestedService: department,
|
||||
},
|
||||
},
|
||||
);
|
||||
);
|
||||
} 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)}`;
|
||||
if (result?.createAppointment?.id) {
|
||||
@@ -171,25 +222,53 @@ const collectLeadInfo = llm.tool({
|
||||
console.log(`[LIVEKIT-AGENT] Lead: ${name} | ${phoneNumber} | ${interest}`);
|
||||
|
||||
const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10);
|
||||
const result = await gql(
|
||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||
{
|
||||
data: {
|
||||
name: `AI Enquiry — ${name}`,
|
||||
contactName: {
|
||||
firstName: name.split(' ')[0],
|
||||
lastName: name.split(' ').slice(1).join(' ') || '',
|
||||
},
|
||||
contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` },
|
||||
source: 'PHONE',
|
||||
status: 'NEW',
|
||||
interestedService: interest,
|
||||
},
|
||||
},
|
||||
);
|
||||
const resolved = await resolveCaller(cleanPhone);
|
||||
const fn = name.split(' ')[0];
|
||||
const ln = name.split(' ').slice(1).join(' ') || '';
|
||||
|
||||
if (result?.createLead?.id) {
|
||||
console.log(`[LIVEKIT-AGENT] Lead created: ${result.createLead.id}`);
|
||||
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 } }`,
|
||||
{
|
||||
data: {
|
||||
name: `AI Enquiry — ${name}`,
|
||||
contactName: { firstName: fn, lastName: ln },
|
||||
contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` },
|
||||
source: 'PHONE',
|
||||
status: 'NEW',
|
||||
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) {
|
||||
await gql(
|
||||
`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.`;
|
||||
},
|
||||
|
||||
49
src/main.ts
49
src/main.ts
@@ -1,38 +1,31 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { join } from 'path';
|
||||
import { AppModule } from './app.module';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const config = app.get(ConfigService);
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
const config = app.get(ConfigService);
|
||||
|
||||
const corsOrigins = config.get<string[]>('corsOrigins') || [
|
||||
'http://localhost:5173',
|
||||
];
|
||||
app.enableCors({
|
||||
origin: config.get('corsOrigin'),
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
app.enableCors({
|
||||
origin: corsOrigins,
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Accept', 'Authorization'],
|
||||
});
|
||||
// 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 swaggerConfig = new DocumentBuilder()
|
||||
.setTitle('Helix Engage Server')
|
||||
.setDescription(
|
||||
'Sidecar API — Ozonetel telephony + FortyTwo platform bridge',
|
||||
)
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, swaggerConfig);
|
||||
SwaggerModule.setup('api/docs', app, document);
|
||||
|
||||
const port = config.get('port');
|
||||
await app.listen(port);
|
||||
console.log(`Helix Engage Server running on port ${port}`);
|
||||
console.log(`Swagger UI: http://localhost:${port}/api/docs`);
|
||||
const port = config.get('port');
|
||||
await app.listen(port);
|
||||
console.log(`Helix Engage Server running on port ${port}`);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Controller, Post, UseGuards, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Body, Controller, HttpException, Post, UseGuards, Logger } from '@nestjs/common';
|
||||
import { MaintGuard } from './maint.guard';
|
||||
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { SessionService } from '../auth/session.service';
|
||||
import { SupervisorService } from '../supervisor/supervisor.service';
|
||||
import { AgentHistoryService, AgentEventType } from '../supervisor/agent-history.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')
|
||||
@UseGuards(MaintGuard)
|
||||
@@ -13,19 +16,26 @@ export class MaintController {
|
||||
private readonly logger = new Logger(MaintController.name);
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly telephony: TelephonyConfigService,
|
||||
private readonly ozonetel: OzonetelAgentService,
|
||||
private readonly platform: PlatformGraphqlService,
|
||||
private readonly session: SessionService,
|
||||
private readonly supervisor: SupervisorService,
|
||||
private readonly callerResolution: CallerResolutionService,
|
||||
private readonly history: AgentHistoryService,
|
||||
private readonly agentLookup: AgentLookupService,
|
||||
private readonly cdrEnrichment: CdrEnrichmentService,
|
||||
) {}
|
||||
|
||||
@Post('force-ready')
|
||||
async forceReady() {
|
||||
const agentId = this.config.get<string>('OZONETEL_AGENT_ID') ?? 'agent3';
|
||||
const password = process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$';
|
||||
const sipId = this.config.get<string>('OZONETEL_SIP_ID') ?? '521814';
|
||||
async forceReady(@Body() body: { agentId: string }) {
|
||||
if (!body?.agentId) throw new HttpException('agentId required', 400);
|
||||
const agentId = body.agentId;
|
||||
const oz = this.telephony.getConfig().ozonetel;
|
||||
const password = oz.agentPassword;
|
||||
if (!password) throw new HttpException('agent password not configured', 400);
|
||||
const sipId = oz.sipId;
|
||||
if (!sipId) throw new HttpException('SIP ID not configured', 400);
|
||||
|
||||
this.logger.log(`[MAINT] Force ready: agent=${agentId}`);
|
||||
|
||||
@@ -47,8 +57,9 @@ export class MaintController {
|
||||
}
|
||||
|
||||
@Post('unlock-agent')
|
||||
async unlockAgent() {
|
||||
const agentId = this.config.get<string>('OZONETEL_AGENT_ID') ?? 'agent3';
|
||||
async unlockAgent(@Body() body: { agentId: string }) {
|
||||
if (!body?.agentId) throw new HttpException('agentId required', 400);
|
||||
const agentId = body.agentId;
|
||||
this.logger.log(`[MAINT] Unlock agent session: ${agentId}`);
|
||||
|
||||
try {
|
||||
@@ -266,6 +277,7 @@ export class MaintController {
|
||||
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||
{
|
||||
data: {
|
||||
name: `${firstName} ${lastName}`.trim(),
|
||||
fullName: { firstName, lastName },
|
||||
phones: { primaryPhoneNumber: `+91${phone}` },
|
||||
patientType: 'NEW',
|
||||
@@ -312,4 +324,586 @@ export class MaintController {
|
||||
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 } };
|
||||
}
|
||||
|
||||
// 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 };
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
150
src/ozonetel/cdr-enrichment.service.ts
Normal file
150
src/ozonetel/cdr-enrichment.service.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
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.
|
||||
const byUcid = new Map<string, any>();
|
||||
for (const row of cdrRows) {
|
||||
const ucid = String(row.UCID ?? '').trim();
|
||||
if (ucid) byUcid.set(ucid, 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> = {};
|
||||
const cdrAgentId = cdrRow.AgentID;
|
||||
if (cdrAgentId && !call.agentId) {
|
||||
const uuid = await this.agentLookup.resolveByOzonetelId(cdrAgentId);
|
||||
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,55 +1,53 @@
|
||||
import { Controller, Get, Query, Logger, Header } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||
|
||||
@Controller('kookoo')
|
||||
export class KookooIvrController {
|
||||
private readonly logger = new Logger(KookooIvrController.name);
|
||||
private readonly sipId: string;
|
||||
private readonly callerId: string;
|
||||
private readonly logger = new Logger(KookooIvrController.name);
|
||||
|
||||
constructor(private config: ConfigService) {
|
||||
this.sipId = process.env.OZONETEL_SIP_ID ?? '523590';
|
||||
this.callerId = process.env.OZONETEL_DID ?? '918041763265';
|
||||
}
|
||||
constructor(private telephony: TelephonyConfigService) {}
|
||||
|
||||
@Get('ivr')
|
||||
@Header('Content-Type', 'application/xml')
|
||||
handleIvr(@Query() query: Record<string, any>): string {
|
||||
const event = query.event ?? '';
|
||||
const sid = query.sid ?? '';
|
||||
const cid = query.cid ?? '';
|
||||
const status = query.status ?? '';
|
||||
private get sipId(): string {
|
||||
return this.telephony.getConfig().ozonetel.sipId || '523590';
|
||||
}
|
||||
private get callerId(): string {
|
||||
return this.telephony.getConfig().ozonetel.did || '918041763265';
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Kookoo IVR: event=${event} sid=${sid} cid=${cid} status=${status}`,
|
||||
);
|
||||
@Get('ivr')
|
||||
@Header('Content-Type', 'application/xml')
|
||||
handleIvr(@Query() query: Record<string, any>): string {
|
||||
const event = query.event ?? '';
|
||||
const sid = query.sid ?? '';
|
||||
const cid = query.cid ?? '';
|
||||
const status = query.status ?? '';
|
||||
|
||||
// New outbound call — customer answered, put them in a conference room
|
||||
// The room ID is based on the call SID so we can join from the browser
|
||||
if (event === 'NewCall') {
|
||||
this.logger.log(
|
||||
`Customer ${cid} answered — dialing DID ${this.callerId} to route to agent`,
|
||||
);
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
this.logger.log(`Kookoo IVR: event=${event} sid=${sid} cid=${cid} status=${status}`);
|
||||
|
||||
// New outbound call — customer answered, put them in a conference room
|
||||
// The room ID is based on the call SID so we can join from the browser
|
||||
if (event === 'NewCall') {
|
||||
this.logger.log(`Customer ${cid} answered — dialing DID ${this.callerId} to route to agent`);
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<response>
|
||||
<dial record="true" timeout="30" moh="ring">${this.callerId}</dial>
|
||||
</response>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Conference event — user left with #
|
||||
if (event === 'conference' || event === 'Conference') {
|
||||
this.logger.log(`Conference event: status=${status}`);
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
// Conference event — user left with #
|
||||
if (event === 'conference' || event === 'Conference') {
|
||||
this.logger.log(`Conference event: status=${status}`);
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<response>
|
||||
<hangup/>
|
||||
</response>`;
|
||||
}
|
||||
|
||||
// Dial or Disconnect
|
||||
this.logger.log(`Call ended: event=${event}`);
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<response>
|
||||
<hangup/>
|
||||
</response>`;
|
||||
}
|
||||
|
||||
// Dial or Disconnect
|
||||
this.logger.log(`Call ended: event=${event}`);
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<response>
|
||||
<hangup/>
|
||||
</response>`;
|
||||
}
|
||||
}
|
||||
|
||||
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,40 @@
|
||||
import { Controller, Post, Get, Body, Query, Logger, HttpException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { OzonetelAgentService } from './ozonetel-agent.service';
|
||||
import { MissedQueueService } from '../worklist/missed-queue.service';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { AgentLookupService } from '../platform/agent-lookup.service';
|
||||
import { EventBusService } from '../events/event-bus.service';
|
||||
import { Topics } from '../events/event-types';
|
||||
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||
import { SupervisorService } from '../supervisor/supervisor.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')
|
||||
export class OzonetelAgentController {
|
||||
private readonly logger = new Logger(OzonetelAgentController.name);
|
||||
private readonly defaultAgentId: string;
|
||||
private readonly defaultAgentPassword: string;
|
||||
|
||||
private readonly defaultSipId: string;
|
||||
|
||||
constructor(
|
||||
private readonly ozonetelAgent: OzonetelAgentService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly telephony: TelephonyConfigService,
|
||||
private readonly missedQueue: MissedQueueService,
|
||||
private readonly platform: PlatformGraphqlService,
|
||||
private readonly eventBus: EventBusService,
|
||||
) {
|
||||
this.defaultAgentId = config.get<string>('OZONETEL_AGENT_ID') ?? 'agent3';
|
||||
this.defaultAgentPassword = config.get<string>('OZONETEL_AGENT_PASSWORD') ?? '';
|
||||
this.defaultSipId = config.get<string>('OZONETEL_SIP_ID') ?? '521814';
|
||||
private readonly supervisor: SupervisorService,
|
||||
private readonly agentLookup: AgentLookupService,
|
||||
) {}
|
||||
|
||||
private requireAgentId(agentId: string | undefined | null): string {
|
||||
if (!agentId) throw new HttpException('agentId required', 400);
|
||||
return agentId;
|
||||
}
|
||||
|
||||
@Post('agent-login')
|
||||
@@ -62,17 +73,18 @@ export class OzonetelAgentController {
|
||||
|
||||
@Post('agent-state')
|
||||
async agentState(
|
||||
@Body() body: { state: 'Ready' | 'Pause'; pauseReason?: string },
|
||||
@Body() body: { agentId: string; state: 'Ready' | 'Pause'; pauseReason?: string },
|
||||
) {
|
||||
if (!body.state) {
|
||||
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 {
|
||||
const result = await this.ozonetelAgent.changeAgentState({
|
||||
agentId: this.defaultAgentId,
|
||||
agentId,
|
||||
state: body.state,
|
||||
pauseReason: body.pauseReason,
|
||||
});
|
||||
@@ -81,7 +93,7 @@ export class OzonetelAgentController {
|
||||
// Auto-assign missed call when agent goes Ready
|
||||
if (body.state === 'Ready') {
|
||||
try {
|
||||
const assigned = await this.missedQueue.assignNext(this.defaultAgentId);
|
||||
const assigned = await this.missedQueue.assignNext(agentId);
|
||||
if (assigned) {
|
||||
this.logger.log(`[AGENT-STATE] Auto-assigned missed call ${assigned.id}`);
|
||||
return { ...result, assignedCall: assigned };
|
||||
@@ -107,10 +119,12 @@ export class OzonetelAgentController {
|
||||
@Body() body: {
|
||||
ucid: string;
|
||||
disposition: string;
|
||||
agentId: string;
|
||||
callerPhone?: string;
|
||||
direction?: string;
|
||||
durationSec?: number;
|
||||
leadId?: string;
|
||||
leadName?: string;
|
||||
notes?: string;
|
||||
missedCallId?: string;
|
||||
},
|
||||
@@ -119,13 +133,17 @@ export class OzonetelAgentController {
|
||||
throw new HttpException('ucid and disposition required', 400);
|
||||
}
|
||||
|
||||
const agentId = this.requireAgentId(body.agentId);
|
||||
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 {
|
||||
const result = await this.ozonetelAgent.setDisposition({
|
||||
agentId: this.defaultAgentId,
|
||||
agentId,
|
||||
ucid: body.ucid,
|
||||
disposition: ozonetelDisposition,
|
||||
});
|
||||
@@ -136,20 +154,121 @@ export class OzonetelAgentController {
|
||||
this.logger.error(`[DISPOSE] FAILED: ${message} ${responseData}`);
|
||||
}
|
||||
|
||||
// Create call record for outbound calls. Inbound calls are
|
||||
// created by the webhook — but we skip outbound in the webhook
|
||||
// (they're not "missed calls"). So the dispose endpoint is the
|
||||
// only place that creates the call record for outbound dials.
|
||||
if (body.direction === 'OUTBOUND' && body.callerPhone) {
|
||||
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
|
||||
if (body.missedCallId) {
|
||||
const statusMap: Record<string, string> = {
|
||||
APPOINTMENT_BOOKED: 'CALLBACK_COMPLETED',
|
||||
APPOINTMENT_RESCHEDULED: 'CALLBACK_COMPLETED',
|
||||
APPOINTMENT_CANCELLED: 'CALLBACK_COMPLETED',
|
||||
INFO_PROVIDED: 'CALLBACK_COMPLETED',
|
||||
FOLLOW_UP_SCHEDULED: 'CALLBACK_COMPLETED',
|
||||
CALLBACK_REQUESTED: 'CALLBACK_COMPLETED',
|
||||
NOT_INTERESTED: 'CALLBACK_COMPLETED',
|
||||
WRONG_NUMBER: 'WRONG_NUMBER',
|
||||
NO_ANSWER: 'CALLBACK_ATTEMPTED',
|
||||
};
|
||||
const newStatus = statusMap[body.disposition];
|
||||
if (newStatus) {
|
||||
try {
|
||||
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) {
|
||||
this.logger.warn(`Failed to update missed call status: ${err}`);
|
||||
@@ -159,7 +278,7 @@ export class OzonetelAgentController {
|
||||
|
||||
// Auto-assign next missed call to this agent
|
||||
try {
|
||||
await this.missedQueue.assignNext(this.defaultAgentId);
|
||||
await this.missedQueue.assignNext(agentId);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Auto-assignment after dispose failed: ${err}`);
|
||||
}
|
||||
@@ -168,7 +287,7 @@ export class OzonetelAgentController {
|
||||
this.eventBus.emit(Topics.CALL_COMPLETED, {
|
||||
callId: null,
|
||||
ucid: body.ucid,
|
||||
agentId: this.defaultAgentId,
|
||||
agentId,
|
||||
callerPhone: body.callerPhone ?? '',
|
||||
direction: body.direction ?? 'INBOUND',
|
||||
durationSec: body.durationSec ?? 0,
|
||||
@@ -183,19 +302,27 @@ export class OzonetelAgentController {
|
||||
|
||||
@Post('dial')
|
||||
async dial(
|
||||
@Body() body: { phoneNumber: string; campaignName?: string; leadId?: string },
|
||||
@Body() body: { phoneNumber: string; agentId: string; campaignName?: string; leadId?: string },
|
||||
) {
|
||||
if (!body.phoneNumber) {
|
||||
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 {
|
||||
const result = await this.ozonetelAgent.manualDial({
|
||||
agentId: this.defaultAgentId,
|
||||
agentId,
|
||||
campaignName,
|
||||
customerNumber: body.phoneNumber,
|
||||
});
|
||||
@@ -273,23 +400,40 @@ export class OzonetelAgentController {
|
||||
}
|
||||
|
||||
@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];
|
||||
this.logger.log(`Performance: date=${targetDate} agent=${this.defaultAgentId}`);
|
||||
this.logger.log(`Performance: date=${targetDate} agent=${agent}`);
|
||||
|
||||
const [cdr, summary, aht] = await Promise.all([
|
||||
this.ozonetelAgent.fetchCDR({ date: targetDate }),
|
||||
this.ozonetelAgent.getAgentSummary(this.defaultAgentId, targetDate),
|
||||
this.ozonetelAgent.getAHT(this.defaultAgentId),
|
||||
this.ozonetelAgent.getAgentSummary(agent, targetDate),
|
||||
this.ozonetelAgent.getAHT(agent),
|
||||
]);
|
||||
|
||||
const totalCalls = cdr.length;
|
||||
const inbound = cdr.filter((c: any) => c.Type === 'InBound').length;
|
||||
const outbound = cdr.filter((c: any) => c.Type === 'Manual' || c.Type === 'Progressive').length;
|
||||
const answered = cdr.filter((c: any) => c.Status === 'Answered').length;
|
||||
const missed = cdr.filter((c: any) => c.Status === 'Unanswered' || c.Status === 'NotAnswered').length;
|
||||
// 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 talkTimes = cdr
|
||||
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')
|
||||
.map((c: any) => {
|
||||
const parts = c.TalkTime.split(':').map(Number);
|
||||
@@ -300,12 +444,12 @@ export class OzonetelAgentController {
|
||||
: 0;
|
||||
|
||||
const dispositions: Record<string, number> = {};
|
||||
for (const c of cdr) {
|
||||
for (const c of agentCdr) {
|
||||
const d = (c as any).Disposition || 'No Disposition';
|
||||
dispositions[d] = (dispositions[d] ?? 0) + 1;
|
||||
}
|
||||
|
||||
const appointmentsBooked = cdr.filter((c: any) =>
|
||||
const appointmentsBooked = agentCdr.filter((c: any) =>
|
||||
c.Disposition?.toLowerCase().includes('appointment'),
|
||||
).length;
|
||||
|
||||
@@ -325,10 +469,13 @@ export class OzonetelAgentController {
|
||||
// Campaign only has 'General Enquiry' configured currently
|
||||
const map: Record<string, string> = {
|
||||
'APPOINTMENT_BOOKED': 'General Enquiry',
|
||||
'APPOINTMENT_RESCHEDULED': 'General Enquiry',
|
||||
'APPOINTMENT_CANCELLED': 'General Enquiry',
|
||||
'FOLLOW_UP_SCHEDULED': 'General Enquiry',
|
||||
'INFO_PROVIDED': 'General Enquiry',
|
||||
'NO_ANSWER': 'General Enquiry',
|
||||
'WRONG_NUMBER': 'General Enquiry',
|
||||
'NOT_INTERESTED': 'General Enquiry',
|
||||
'CALLBACK_REQUESTED': 'General Enquiry',
|
||||
};
|
||||
return map[disposition] ?? 'General Enquiry';
|
||||
|
||||
@@ -2,13 +2,15 @@ import { Module, forwardRef } from '@nestjs/common';
|
||||
import { OzonetelAgentController } from './ozonetel-agent.controller';
|
||||
import { OzonetelAgentService } from './ozonetel-agent.service';
|
||||
import { KookooIvrController } from './kookoo-ivr.controller';
|
||||
import { CdrEnrichmentService } from './cdr-enrichment.service';
|
||||
import { WorklistModule } from '../worklist/worklist.module';
|
||||
import { PlatformModule } from '../platform/platform.module';
|
||||
import { SupervisorModule } from '../supervisor/supervisor.module';
|
||||
|
||||
@Module({
|
||||
imports: [PlatformModule, forwardRef(() => WorklistModule)],
|
||||
controllers: [OzonetelAgentController, KookooIvrController],
|
||||
providers: [OzonetelAgentService],
|
||||
exports: [OzonetelAgentService],
|
||||
imports: [PlatformModule, forwardRef(() => WorklistModule), forwardRef(() => SupervisorModule)],
|
||||
controllers: [OzonetelAgentController, KookooIvrController],
|
||||
providers: [OzonetelAgentService, CdrEnrichmentService],
|
||||
exports: [OzonetelAgentService, CdrEnrichmentService],
|
||||
})
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
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;
|
||||
}
|
||||
}
|
||||
@@ -1,59 +1,48 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
import type {
|
||||
LeadNode,
|
||||
LeadActivityNode,
|
||||
CreateCallInput,
|
||||
CreateLeadActivityInput,
|
||||
CreateLeadInput,
|
||||
UpdateLeadInput,
|
||||
} from './platform.types';
|
||||
import type { LeadNode, LeadActivityNode, CreateCallInput, CreateLeadActivityInput, UpdateLeadInput } from './platform.types';
|
||||
|
||||
@Injectable()
|
||||
export class PlatformGraphqlService {
|
||||
private readonly graphqlUrl: string;
|
||||
private readonly apiKey: string;
|
||||
private readonly graphqlUrl: string;
|
||||
private readonly apiKey: string;
|
||||
|
||||
constructor(private config: ConfigService) {
|
||||
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
|
||||
this.apiKey = config.get<string>('platform.apiKey')!;
|
||||
}
|
||||
|
||||
// Server-to-server query using API key
|
||||
async query<T>(query: string, variables?: Record<string, any>): Promise<T> {
|
||||
return this.queryWithAuth<T>(query, variables, `Bearer ${this.apiKey}`);
|
||||
}
|
||||
|
||||
// Query using a passed-through auth header (user JWT)
|
||||
async queryWithAuth<T>(
|
||||
query: string,
|
||||
variables: Record<string, any> | undefined,
|
||||
authHeader: string,
|
||||
): Promise<T> {
|
||||
const response = await axios.post(
|
||||
this.graphqlUrl,
|
||||
{ query, variables },
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: authHeader,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.data.errors) {
|
||||
throw new Error(`GraphQL error: ${JSON.stringify(response.data.errors)}`);
|
||||
constructor(private config: ConfigService) {
|
||||
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
|
||||
this.apiKey = config.get<string>('platform.apiKey')!;
|
||||
}
|
||||
|
||||
return response.data.data;
|
||||
}
|
||||
// Server-to-server query using API key
|
||||
async query<T>(query: string, variables?: Record<string, any>): Promise<T> {
|
||||
return this.queryWithAuth<T>(query, variables, `Bearer ${this.apiKey}`);
|
||||
}
|
||||
|
||||
async findLeadByPhone(phone: string): Promise<LeadNode | null> {
|
||||
// Note: The exact filter syntax for PHONES fields depends on the platform
|
||||
// This queries leads and filters client-side by phone number
|
||||
const data = await this.query<{ leads: { edges: { node: LeadNode }[] } }>(
|
||||
`query FindLeads($first: Int) {
|
||||
// Query using a passed-through auth header (user JWT)
|
||||
async queryWithAuth<T>(query: string, variables: Record<string, any> | undefined, authHeader: string): Promise<T> {
|
||||
const response = await axios.post(
|
||||
this.graphqlUrl,
|
||||
{ query, variables },
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': authHeader,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.data.errors) {
|
||||
throw new Error(`GraphQL error: ${JSON.stringify(response.data.errors)}`);
|
||||
}
|
||||
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async findLeadByPhone(phone: string): Promise<LeadNode | null> {
|
||||
// Note: The exact filter syntax for PHONES fields depends on the platform
|
||||
// This queries leads and filters client-side by phone number
|
||||
const data = await this.query<{ leads: { edges: { node: LeadNode }[] } }>(
|
||||
`query FindLeads($first: Int) {
|
||||
leads(first: $first, orderBy: { createdAt: DescNullsLast }) {
|
||||
edges {
|
||||
node {
|
||||
@@ -69,26 +58,20 @@ export class PlatformGraphqlService {
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ first: 100 },
|
||||
);
|
||||
|
||||
// Client-side phone matching (strip non-digits for comparison)
|
||||
const normalizedPhone = phone.replace(/\D/g, '');
|
||||
return (
|
||||
data.leads.edges.find((edge) => {
|
||||
const leadPhones = edge.node.contactPhone ?? [];
|
||||
return leadPhones.some(
|
||||
(p) =>
|
||||
p.number.replace(/\D/g, '').endsWith(normalizedPhone) ||
|
||||
normalizedPhone.endsWith(p.number.replace(/\D/g, '')),
|
||||
{ first: 100 },
|
||||
);
|
||||
})?.node ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async findLeadById(id: string): Promise<LeadNode | null> {
|
||||
const data = await this.query<{ lead: LeadNode }>(
|
||||
`query FindLead($id: ID!) {
|
||||
// Client-side phone matching (strip non-digits for comparison)
|
||||
const normalizedPhone = phone.replace(/\D/g, '');
|
||||
return data.leads.edges.find(edge => {
|
||||
const leadPhones = edge.node.contactPhone ?? [];
|
||||
return leadPhones.some(p => p.number.replace(/\D/g, '').endsWith(normalizedPhone) || normalizedPhone.endsWith(p.number.replace(/\D/g, '')));
|
||||
})?.node ?? null;
|
||||
}
|
||||
|
||||
async findLeadById(id: string): Promise<LeadNode | null> {
|
||||
const data = await this.query<{ lead: LeadNode }>(
|
||||
`query FindLead($id: ID!) {
|
||||
lead(id: $id) {
|
||||
id createdAt
|
||||
contactName { firstName lastName }
|
||||
@@ -100,68 +83,51 @@ export class PlatformGraphqlService {
|
||||
aiSummary aiSuggestedAction
|
||||
}
|
||||
}`,
|
||||
{ id },
|
||||
);
|
||||
return data.lead;
|
||||
}
|
||||
{ id },
|
||||
);
|
||||
return data.lead;
|
||||
}
|
||||
|
||||
async updateLead(id: string, input: UpdateLeadInput): Promise<LeadNode> {
|
||||
const data = await this.query<{ updateLead: LeadNode }>(
|
||||
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
|
||||
async updateLead(id: string, input: UpdateLeadInput): Promise<LeadNode> {
|
||||
const data = await this.query<{ updateLead: LeadNode }>(
|
||||
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
|
||||
updateLead(id: $id, data: $data) {
|
||||
id leadStatus aiSummary aiSuggestedAction
|
||||
}
|
||||
}`,
|
||||
{ id, data: input },
|
||||
);
|
||||
return data.updateLead;
|
||||
}
|
||||
{ id, data: input },
|
||||
);
|
||||
return data.updateLead;
|
||||
}
|
||||
|
||||
async createCall(input: CreateCallInput): Promise<{ id: string }> {
|
||||
const data = await this.query<{ createCall: { id: string } }>(
|
||||
`mutation CreateCall($data: CallCreateInput!) {
|
||||
async createCall(input: CreateCallInput): Promise<{ id: string }> {
|
||||
const data = await this.query<{ createCall: { id: string } }>(
|
||||
`mutation CreateCall($data: CallCreateInput!) {
|
||||
createCall(data: $data) { id }
|
||||
}`,
|
||||
{ data: input },
|
||||
);
|
||||
return data.createCall;
|
||||
}
|
||||
{ data: input },
|
||||
);
|
||||
return data.createCall;
|
||||
}
|
||||
|
||||
async createLeadActivity(
|
||||
input: CreateLeadActivityInput,
|
||||
): Promise<{ id: string }> {
|
||||
const data = await this.query<{ createLeadActivity: { id: string } }>(
|
||||
`mutation CreateLeadActivity($data: LeadActivityCreateInput!) {
|
||||
async createLeadActivity(input: CreateLeadActivityInput): Promise<{ id: string }> {
|
||||
const data = await this.query<{ createLeadActivity: { id: string } }>(
|
||||
`mutation CreateLeadActivity($data: LeadActivityCreateInput!) {
|
||||
createLeadActivity(data: $data) { id }
|
||||
}`,
|
||||
{ data: input },
|
||||
);
|
||||
return data.createLeadActivity;
|
||||
}
|
||||
{ data: input },
|
||||
);
|
||||
return data.createLeadActivity;
|
||||
}
|
||||
|
||||
async createLead(input: CreateLeadInput): Promise<{ id: string }> {
|
||||
const data = await this.query<{ createLead: { id: string } }>(
|
||||
`mutation CreateLead($data: LeadCreateInput!) {
|
||||
createLead(data: $data) { id }
|
||||
}`,
|
||||
{ data: input },
|
||||
);
|
||||
return data.createLead;
|
||||
}
|
||||
// --- Token passthrough versions (for user-driven requests) ---
|
||||
|
||||
// --- Token passthrough versions (for user-driven requests) ---
|
||||
async findLeadByPhoneWithToken(phone: string, authHeader: string): Promise<LeadNode | null> {
|
||||
const normalizedPhone = phone.replace(/\D/g, '');
|
||||
const last10 = normalizedPhone.slice(-10);
|
||||
|
||||
async findLeadByPhoneWithToken(
|
||||
phone: string,
|
||||
authHeader: string,
|
||||
): Promise<LeadNode | null> {
|
||||
const normalizedPhone = phone.replace(/\D/g, '');
|
||||
const last10 = normalizedPhone.slice(-10);
|
||||
|
||||
const data = await this.queryWithAuth<{
|
||||
leads: { edges: { node: LeadNode }[] };
|
||||
}>(
|
||||
`query FindLeads($first: Int) {
|
||||
const data = await this.queryWithAuth<{ leads: { edges: { node: LeadNode }[] } }>(
|
||||
`query FindLeads($first: Int) {
|
||||
leads(first: $first, orderBy: [{ createdAt: DescNullsLast }]) {
|
||||
edges {
|
||||
node {
|
||||
@@ -177,43 +143,28 @@ export class PlatformGraphqlService {
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ first: 200 },
|
||||
authHeader,
|
||||
);
|
||||
{ first: 200 },
|
||||
authHeader,
|
||||
);
|
||||
|
||||
// Client-side phone matching
|
||||
return (
|
||||
data.leads.edges.find((edge) => {
|
||||
const phones = edge.node.contactPhone ?? [];
|
||||
if (Array.isArray(phones)) {
|
||||
return phones.some((p: any) => {
|
||||
const num = (p.number ?? p.primaryPhoneNumber ?? '').replace(
|
||||
/\D/g,
|
||||
'',
|
||||
);
|
||||
// Client-side phone matching
|
||||
return data.leads.edges.find(edge => {
|
||||
const phones = edge.node.contactPhone ?? [];
|
||||
if (Array.isArray(phones)) {
|
||||
return phones.some((p: any) => {
|
||||
const num = (p.number ?? p.primaryPhoneNumber ?? '').replace(/\D/g, '');
|
||||
return num.endsWith(last10) || last10.endsWith(num);
|
||||
});
|
||||
}
|
||||
// Handle single phone object
|
||||
const num = ((phones as any).primaryPhoneNumber ?? (phones as any).number ?? '').replace(/\D/g, '');
|
||||
return num.endsWith(last10) || last10.endsWith(num);
|
||||
});
|
||||
}
|
||||
// Handle single phone object
|
||||
const num = (
|
||||
(phones as any).primaryPhoneNumber ??
|
||||
(phones as any).number ??
|
||||
''
|
||||
).replace(/\D/g, '');
|
||||
return num.endsWith(last10) || last10.endsWith(num);
|
||||
})?.node ?? null
|
||||
);
|
||||
}
|
||||
})?.node ?? null;
|
||||
}
|
||||
|
||||
async getLeadActivitiesWithToken(
|
||||
leadId: string,
|
||||
authHeader: string,
|
||||
limit = 5,
|
||||
): Promise<LeadActivityNode[]> {
|
||||
const data = await this.queryWithAuth<{
|
||||
leadActivities: { edges: { node: LeadActivityNode }[] };
|
||||
}>(
|
||||
`query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) {
|
||||
async getLeadActivitiesWithToken(leadId: string, authHeader: string, limit = 5): Promise<LeadActivityNode[]> {
|
||||
const data = await this.queryWithAuth<{ leadActivities: { edges: { node: LeadActivityNode }[] } }>(
|
||||
`query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) {
|
||||
leadActivities(filter: $filter, first: $first, orderBy: [{ occurredAt: DescNullsLast }]) {
|
||||
edges {
|
||||
node {
|
||||
@@ -222,39 +173,97 @@ export class PlatformGraphqlService {
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ filter: { leadId: { eq: leadId } }, first: limit },
|
||||
authHeader,
|
||||
);
|
||||
return data.leadActivities.edges.map((e) => e.node);
|
||||
}
|
||||
{ filter: { leadId: { eq: leadId } }, first: limit },
|
||||
authHeader,
|
||||
);
|
||||
return data.leadActivities.edges.map(e => e.node);
|
||||
}
|
||||
|
||||
async updateLeadWithToken(
|
||||
id: string,
|
||||
input: UpdateLeadInput,
|
||||
authHeader: string,
|
||||
): Promise<LeadNode> {
|
||||
const data = await this.queryWithAuth<{ updateLead: LeadNode }>(
|
||||
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
|
||||
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 }>(
|
||||
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
|
||||
updateLead(id: $id, data: $data) {
|
||||
id leadStatus aiSummary aiSuggestedAction
|
||||
id aiSummary aiSuggestedAction
|
||||
}
|
||||
}`,
|
||||
{ id, data: input },
|
||||
authHeader,
|
||||
);
|
||||
return data.updateLead;
|
||||
}
|
||||
{ id, data: input },
|
||||
authHeader,
|
||||
);
|
||||
return data.updateLead;
|
||||
}
|
||||
|
||||
// --- Server-to-server versions (for webhooks, background jobs) ---
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
async getLeadActivities(
|
||||
leadId: string,
|
||||
limit = 3,
|
||||
): Promise<LeadActivityNode[]> {
|
||||
const data = await this.query<{
|
||||
leadActivities: { edges: { node: LeadActivityNode }[] };
|
||||
}>(
|
||||
`query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) {
|
||||
// --- Server-to-server versions (for webhooks, background jobs) ---
|
||||
|
||||
async getLeadActivities(leadId: string, limit = 3): Promise<LeadActivityNode[]> {
|
||||
const data = await this.query<{ leadActivities: { edges: { node: LeadActivityNode }[] } }>(
|
||||
`query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) {
|
||||
leadActivities(filter: $filter, first: $first, orderBy: { occurredAt: DescNullsLast }) {
|
||||
edges {
|
||||
node {
|
||||
@@ -263,8 +272,8 @@ export class PlatformGraphqlService {
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ filter: { leadId: { eq: leadId } }, first: limit },
|
||||
);
|
||||
return data.leadActivities.edges.map((e) => e.node);
|
||||
}
|
||||
{ filter: { leadId: { eq: leadId } }, first: limit },
|
||||
);
|
||||
return data.leadActivities.edges.map(e => e.node);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PlatformGraphqlService } from './platform-graphql.service';
|
||||
import { AgentLookupService } from './agent-lookup.service';
|
||||
|
||||
@Module({
|
||||
providers: [PlatformGraphqlService],
|
||||
exports: [PlatformGraphqlService],
|
||||
providers: [PlatformGraphqlService, AgentLookupService],
|
||||
exports: [PlatformGraphqlService, AgentLookupService],
|
||||
})
|
||||
export class PlatformModule {}
|
||||
|
||||
@@ -63,19 +63,6 @@ export type CreateLeadActivityInput = {
|
||||
leadId: string;
|
||||
};
|
||||
|
||||
export type CreateLeadInput = {
|
||||
name: string;
|
||||
contactName?: { firstName: string; lastName?: string };
|
||||
contactPhone?: { primaryPhoneNumber: string };
|
||||
contactEmail?: { primaryEmailAddress: string };
|
||||
source?: string;
|
||||
status?: string;
|
||||
interestedService?: string;
|
||||
assignedAgent?: string;
|
||||
campaignId?: string;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
export type UpdateLeadInput = {
|
||||
leadStatus?: string;
|
||||
lastContactedAt?: string;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { generateObject } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { createAiModel } from '../ai/ai-provider';
|
||||
import type { LanguageModel } from 'ai';
|
||||
import { AiConfigService } from '../config/ai-config.service';
|
||||
|
||||
const DEEPGRAM_API = 'https://api.deepgram.com/v1/listen';
|
||||
|
||||
@@ -44,9 +45,18 @@ export class RecordingsService {
|
||||
private readonly deepgramApiKey: string;
|
||||
private readonly aiModel: LanguageModel | null;
|
||||
|
||||
constructor(private config: ConfigService) {
|
||||
constructor(
|
||||
private config: ConfigService,
|
||||
private aiConfig: AiConfigService,
|
||||
) {
|
||||
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> {
|
||||
@@ -225,11 +235,11 @@ The CUSTOMER typically:
|
||||
patientSatisfaction: z.string().describe('One-line assessment of patient satisfaction'),
|
||||
callOutcome: z.string().describe('One-line summary of what was accomplished'),
|
||||
}),
|
||||
system: `You are a call quality analyst for Global Hospital Bangalore.
|
||||
Analyze the following call recording transcript and provide structured insights.
|
||||
Be specific, brief, and actionable. Focus on healthcare context.
|
||||
${summary ? `\nCall summary: ${summary}` : ''}
|
||||
${topics.length > 0 ? `\nDetected topics: ${topics.join(', ')}` : ''}`,
|
||||
system: this.aiConfig.renderPrompt('recordingAnalysis', {
|
||||
hospitalName: process.env.HOSPITAL_NAME ?? 'the hospital',
|
||||
summaryBlock: summary ? `\nCall summary: ${summary}` : '',
|
||||
topicsBlock: topics.length > 0 ? `\nDetected topics: ${topics.join(', ')}` : '',
|
||||
}),
|
||||
prompt: transcript,
|
||||
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 { 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 {
|
||||
type = 'escalate';
|
||||
private readonly logger = new Logger(EscalateActionHandler.name);
|
||||
|
||||
async execute(_action: RuleAction, _context: Record<string, any>): Promise<ActionResult> {
|
||||
return { success: true, data: { stub: true, action: 'escalate' } };
|
||||
constructor(private readonly platform: PlatformGraphqlService) {}
|
||||
|
||||
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.disposition': call.disposition ?? null,
|
||||
'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.slaBreached': slaElapsedPercent > 100,
|
||||
'call.missedCount': call.missedcallcount ?? call.missedCount ?? 0,
|
||||
'call.missedCount': call.missedCallCount ?? call.missedCount ?? 0,
|
||||
'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
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PlatformModule } from '../platform/platform.module';
|
||||
import { RulesEngineController } from './rules-engine.controller';
|
||||
import { RulesEngineService } from './rules-engine.service';
|
||||
import { RulesStorageService } from './rules-storage.service';
|
||||
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({
|
||||
imports: [PlatformModule],
|
||||
controllers: [RulesEngineController],
|
||||
providers: [RulesEngineService, RulesStorageService, WorklistConsumer],
|
||||
exports: [RulesEngineService, RulesStorageService, WorklistConsumer],
|
||||
providers: [
|
||||
RulesEngineService,
|
||||
RulesStorageService,
|
||||
WorklistConsumer,
|
||||
PerformanceConsumer,
|
||||
EscalateActionHandler,
|
||||
PerformanceFactsProvider,
|
||||
],
|
||||
exports: [RulesEngineService, RulesStorageService, WorklistConsumer, PerformanceConsumer],
|
||||
})
|
||||
export class RulesEngineModule {}
|
||||
|
||||
@@ -20,11 +20,14 @@ export class RulesEngineService {
|
||||
private readonly agentFacts = new AgentFactsProvider();
|
||||
private readonly actionHandlers: Map<string, ActionHandler>;
|
||||
|
||||
constructor(private readonly storage: RulesStorageService) {
|
||||
constructor(
|
||||
private readonly storage: RulesStorageService,
|
||||
private readonly escalateHandler: EscalateActionHandler,
|
||||
) {
|
||||
this.actionHandlers = new Map([
|
||||
['score', new ScoreActionHandler()],
|
||||
['assign', new AssignActionHandler()],
|
||||
['escalate', new EscalateActionHandler()],
|
||||
['escalate', this.escalateHandler],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,51 @@
|
||||
"trigger": { "type": "on_schedule", "interval": "5m" },
|
||||
"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" } }
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,113 +4,91 @@ import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
|
||||
@Controller('api/search')
|
||||
export class SearchController {
|
||||
private readonly logger = new Logger(SearchController.name);
|
||||
private readonly platformApiKey: string;
|
||||
private readonly logger = new Logger(SearchController.name);
|
||||
private readonly platformApiKey: string;
|
||||
|
||||
constructor(
|
||||
private readonly platform: PlatformGraphqlService,
|
||||
private readonly config: ConfigService,
|
||||
) {
|
||||
this.platformApiKey = config.get<string>('platform.apiKey') ?? '';
|
||||
}
|
||||
|
||||
@Get()
|
||||
async search(@Query('q') query?: string) {
|
||||
if (!query || query.length < 2) {
|
||||
return { leads: [], patients: [], appointments: [] };
|
||||
constructor(
|
||||
private readonly platform: PlatformGraphqlService,
|
||||
private readonly config: ConfigService,
|
||||
) {
|
||||
this.platformApiKey = config.get<string>('platform.apiKey') ?? '';
|
||||
}
|
||||
|
||||
const authHeader = this.platformApiKey
|
||||
? `Bearer ${this.platformApiKey}`
|
||||
: '';
|
||||
if (!authHeader) {
|
||||
return { leads: [], patients: [], appointments: [] };
|
||||
}
|
||||
@Get()
|
||||
async search(@Query('q') query?: string) {
|
||||
if (!query || query.length < 2) {
|
||||
return { leads: [], patients: [], appointments: [] };
|
||||
}
|
||||
|
||||
this.logger.log(`Search: "${query}"`);
|
||||
const authHeader = this.platformApiKey ? `Bearer ${this.platformApiKey}` : '';
|
||||
if (!authHeader) {
|
||||
return { leads: [], patients: [], appointments: [] };
|
||||
}
|
||||
|
||||
// Fetch all three in parallel, filter client-side for flexible matching
|
||||
try {
|
||||
const [leadsResult, patientsResult, appointmentsResult] =
|
||||
await Promise.all([
|
||||
this.platform
|
||||
.queryWithAuth<any>(
|
||||
`{ leads(first: 50) { edges { node {
|
||||
this.logger.log(`Search: "${query}"`);
|
||||
|
||||
// Fetch all three in parallel, filter client-side for flexible matching
|
||||
try {
|
||||
const [leadsResult, patientsResult, appointmentsResult] = await Promise.all([
|
||||
this.platform.queryWithAuth<any>(
|
||||
`{ leads(first: 50) { edges { node {
|
||||
id name contactName { firstName lastName }
|
||||
contactPhone { primaryPhoneNumber }
|
||||
source status interestedService
|
||||
} } } }`,
|
||||
undefined,
|
||||
authHeader,
|
||||
)
|
||||
.catch(() => ({ leads: { edges: [] } })),
|
||||
undefined, authHeader,
|
||||
).catch(() => ({ leads: { edges: [] } })),
|
||||
|
||||
this.platform
|
||||
.queryWithAuth<any>(
|
||||
`{ patients(first: 50) { edges { node {
|
||||
this.platform.queryWithAuth<any>(
|
||||
`{ patients(first: 50) { edges { node {
|
||||
id name fullName { firstName lastName }
|
||||
phones { primaryPhoneNumber }
|
||||
gender dateOfBirth
|
||||
} } } }`,
|
||||
undefined,
|
||||
authHeader,
|
||||
)
|
||||
.catch(() => ({ patients: { edges: [] } })),
|
||||
undefined, authHeader,
|
||||
).catch(() => ({ patients: { edges: [] } })),
|
||||
|
||||
this.platform
|
||||
.queryWithAuth<any>(
|
||||
`{ appointments(first: 50, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||
this.platform.queryWithAuth<any>(
|
||||
`{ appointments(first: 50, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||
id scheduledAt doctorName department status patientId
|
||||
} } } }`,
|
||||
undefined,
|
||||
authHeader,
|
||||
)
|
||||
.catch(() => ({ appointments: { edges: [] } })),
|
||||
]);
|
||||
undefined, authHeader,
|
||||
).catch(() => ({ appointments: { edges: [] } })),
|
||||
]);
|
||||
|
||||
const q = query.toLowerCase();
|
||||
const q = query.toLowerCase();
|
||||
|
||||
const leads = (leadsResult.leads?.edges ?? [])
|
||||
.map((e: any) => e.node)
|
||||
.filter((l: any) => {
|
||||
const name =
|
||||
`${l.contactName?.firstName ?? ''} ${l.contactName?.lastName ?? ''}`.toLowerCase();
|
||||
const phone = l.contactPhone?.primaryPhoneNumber ?? '';
|
||||
return (
|
||||
name.includes(q) ||
|
||||
phone.includes(q) ||
|
||||
(l.name ?? '').toLowerCase().includes(q)
|
||||
);
|
||||
})
|
||||
.slice(0, 5);
|
||||
const leads = (leadsResult.leads?.edges ?? [])
|
||||
.map((e: any) => e.node)
|
||||
.filter((l: any) => {
|
||||
const name = `${l.contactName?.firstName ?? ''} ${l.contactName?.lastName ?? ''}`.toLowerCase();
|
||||
const phone = l.contactPhone?.primaryPhoneNumber ?? '';
|
||||
return name.includes(q) || phone.includes(q) || (l.name ?? '').toLowerCase().includes(q);
|
||||
})
|
||||
.slice(0, 5);
|
||||
|
||||
const patients = (patientsResult.patients?.edges ?? [])
|
||||
.map((e: any) => e.node)
|
||||
.filter((p: any) => {
|
||||
const name =
|
||||
`${p.fullName?.firstName ?? ''} ${p.fullName?.lastName ?? ''}`.toLowerCase();
|
||||
const phone = p.phones?.primaryPhoneNumber ?? '';
|
||||
return (
|
||||
name.includes(q) ||
|
||||
phone.includes(q) ||
|
||||
(p.name ?? '').toLowerCase().includes(q)
|
||||
);
|
||||
})
|
||||
.slice(0, 5);
|
||||
const patients = (patientsResult.patients?.edges ?? [])
|
||||
.map((e: any) => e.node)
|
||||
.filter((p: any) => {
|
||||
const name = `${p.fullName?.firstName ?? ''} ${p.fullName?.lastName ?? ''}`.toLowerCase();
|
||||
const phone = p.phones?.primaryPhoneNumber ?? '';
|
||||
return name.includes(q) || phone.includes(q) || (p.name ?? '').toLowerCase().includes(q);
|
||||
})
|
||||
.slice(0, 5);
|
||||
|
||||
const appointments = (appointmentsResult.appointments?.edges ?? [])
|
||||
.map((e: any) => e.node)
|
||||
.filter((a: any) => {
|
||||
const doctor = (a.doctorName ?? '').toLowerCase();
|
||||
const dept = (a.department ?? '').toLowerCase();
|
||||
return doctor.includes(q) || dept.includes(q);
|
||||
})
|
||||
.slice(0, 5);
|
||||
const appointments = (appointmentsResult.appointments?.edges ?? [])
|
||||
.map((e: any) => e.node)
|
||||
.filter((a: any) => {
|
||||
const doctor = (a.doctorName ?? '').toLowerCase();
|
||||
const dept = (a.department ?? '').toLowerCase();
|
||||
return doctor.includes(q) || dept.includes(q);
|
||||
})
|
||||
.slice(0, 5);
|
||||
|
||||
return { leads, patients, appointments };
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Search failed: ${err.message}`);
|
||||
return { leads: [], patients: [], appointments: [] };
|
||||
return { leads, patients, appointments };
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Search failed: ${err.message}`);
|
||||
return { leads: [], patients: [], appointments: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { SearchController } from './search.controller';
|
||||
import { PlatformModule } from '../platform/platform.module';
|
||||
|
||||
@Module({
|
||||
imports: [PlatformModule],
|
||||
controllers: [SearchController],
|
||||
imports: [PlatformModule],
|
||||
controllers: [SearchController],
|
||||
})
|
||||
export class SearchModule {}
|
||||
|
||||
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,13 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { PlatformModule } from '../platform/platform.module';
|
||||
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
||||
import { SupervisorController } from './supervisor.controller';
|
||||
import { SupervisorBargeController } from './supervisor-barge.controller';
|
||||
import { PerformanceAlertsController } from './performance-alerts.controller';
|
||||
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({
|
||||
imports: [PlatformModule, OzonetelAgentModule],
|
||||
controllers: [SupervisorController],
|
||||
providers: [SupervisorService],
|
||||
exports: [SupervisorService],
|
||||
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule)],
|
||||
controllers: [SupervisorController, SupervisorBargeController, PerformanceAlertsController],
|
||||
providers: [SupervisorService, AgentHistoryService, OzonetelAdminAuthService],
|
||||
exports: [SupervisorService, AgentHistoryService, OzonetelAdminAuthService],
|
||||
})
|
||||
export class SupervisorModule {}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { Subject } from 'rxjs';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
||||
import { AgentHistoryService, AgentEventType } from './agent-history.service';
|
||||
|
||||
type ActiveCall = {
|
||||
ucid: string;
|
||||
@@ -20,23 +21,53 @@ type AgentStateEntry = {
|
||||
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()
|
||||
export class SupervisorService implements OnModuleInit {
|
||||
private readonly logger = new Logger(SupervisorService.name);
|
||||
private readonly activeCalls = new Map<string, ActiveCall>();
|
||||
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 }>();
|
||||
|
||||
// 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(
|
||||
private platform: PlatformGraphqlService,
|
||||
private ozonetel: OzonetelAgentService,
|
||||
private config: ConfigService,
|
||||
private history: AgentHistoryService,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
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) {
|
||||
const action = event.action;
|
||||
const ucid = event.ucid ?? event.monitorUCID;
|
||||
@@ -44,37 +75,155 @@ export class SupervisorService implements OnModuleInit {
|
||||
const callerNumber = event.caller_id ?? event.callerID;
|
||||
const callType = event.call_type ?? event.Type;
|
||||
const eventTime = event.event_time ?? event.eventTime ?? new Date().toISOString();
|
||||
const iso = this.parseOzonetelTime(eventTime);
|
||||
|
||||
if (!ucid) return;
|
||||
|
||||
if (action === 'Answered' || action === 'Calling') {
|
||||
// Don't show calls for offline agents (ghost calls)
|
||||
const agentState = this.agentStates.get(agentId);
|
||||
if (agentState?.state === 'offline') {
|
||||
this.logger.warn(`Ignoring call event for offline agent ${agentId} (${ucid})`);
|
||||
return;
|
||||
}
|
||||
this.activeCalls.set(ucid, {
|
||||
ucid, agentId, callerNumber,
|
||||
callType, startTime: eventTime, status: 'active',
|
||||
});
|
||||
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(() => {});
|
||||
}
|
||||
} else if (action === 'Disconnect') {
|
||||
const wasActive = this.activeCalls.get(ucid);
|
||||
this.activeCalls.delete(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) {
|
||||
const agentId = event.agentId ?? event.agent_id ?? '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();
|
||||
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) {
|
||||
this.agentStates.set(agentId, { state: mapped, timestamp: eventTime });
|
||||
this.agentStateSubject.next({ agentId, state: mapped, timestamp: eventTime });
|
||||
this.logger.log(`[AGENT-STATE] Emitted: ${agentId} → ${mapped}`);
|
||||
|
||||
// Persist to AgentEvent table. CALL_START/CALL_END are
|
||||
// handled in handleCallEvent (they arrive via a separate
|
||||
// Ozonetel webhook). Everything else is captured here.
|
||||
// Pass priorState so 'release' → RESUME / ACW_END / READY can
|
||||
// be disambiguated for the session rollup.
|
||||
const historyEventType = this.mapToHistoryEventType(action, priorState);
|
||||
if (historyEventType) {
|
||||
const resolvedPauseReason = (pauseReason || eventData || '') || null;
|
||||
this.history.persistAgentEvent({
|
||||
ozonetelAgentId: agentId,
|
||||
eventType: historyEventType,
|
||||
eventAt: this.parseOzonetelTime(eventTime),
|
||||
pauseReason: historyEventType === 'PAUSE' ? resolvedPauseReason : null,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// 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) {
|
||||
case 'release': return 'ready';
|
||||
case 'IDLE': return 'ready'; // agent available after unanswered/canceled call
|
||||
@@ -82,11 +231,16 @@ export class SupervisorService implements OnModuleInit {
|
||||
case 'incall': return 'in-call';
|
||||
case 'ACW': return 'acw';
|
||||
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
|
||||
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';
|
||||
}
|
||||
case 'login': return null; // wait for release
|
||||
default: return null;
|
||||
}
|
||||
@@ -108,29 +262,164 @@ export class SupervisorService implements OnModuleInit {
|
||||
}
|
||||
|
||||
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>(
|
||||
`{ agents(first: 20) { edges { node {
|
||||
id name ozonetelagentid npsscore
|
||||
maxidleminutes minnpsthreshold minconversionpercent
|
||||
id name ozonetelAgentId npsScore
|
||||
maxIdleMinutes minNpsThreshold minConversion
|
||||
} } } }`,
|
||||
);
|
||||
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(
|
||||
agents.map(async (agent: any) => {
|
||||
if (!agent.ozonetelagentid) return { ...agent, timeBreakdown: null };
|
||||
if (!agent.ozonetelAgentId) return { ...agent, timeBreakdown: null, calls: null };
|
||||
try {
|
||||
const summary = await this.ozonetel.getAgentSummary(agent.ozonetelagentid, date);
|
||||
return { ...agent, timeBreakdown: summary };
|
||||
let timeBreakdown: any = null;
|
||||
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) {
|
||||
this.logger.warn(`Failed to get summary for ${agent.ozonetelagentid}: ${err}`);
|
||||
return { ...agent, timeBreakdown: null };
|
||||
this.logger.warn(`Failed to get summary for ${agent.ozonetelAgentId}: ${err}`);
|
||||
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;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user