mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
merge: feature/omnichannel-widget → master
26 commits bringing the full omnichannel call-center stack: Core features: - Team module (in-place employee creation, temp passwords, role assignment) - Multi-stage Dockerfile (fixes cross-arch native module crashes) - Doctor visit slot entity support (shared fragment + normalizer) - AI config CRUD (admin-editable prompts, workspace-scoped setup state) - Widget chat with generative UI, captcha gate, lead dedup - Call assist, supervisor, recordings services updated for new schema - Session service with workspace-scoped Redis key prefixing Infrastructure: - Dockerfile rewritten as multi-stage builder → runtime - package-lock.json regenerated (Verdaccio → public npmjs.org) - .dockerignore hardened Tests (48 passing): - Ozonetel agent service (auth, dial, dispose, state, token cache) - Missed call webhook (parsing, IST→UTC, duration, CallerID) - Missed queue (abandon polling, PENDING_CALLBACK, dedup) - Caller resolution (4-path phone→lead+patient, caching) - Team service (5-step creation, SIP linking, validation) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,14 @@
|
|||||||
|
# Build artifacts and host-installed deps — the multi-stage Dockerfile
|
||||||
|
# rebuilds these inside the container for the target platform, so the
|
||||||
|
# host copies must NOT leak in (would clobber linux/amd64 binaries
|
||||||
|
# with darwin/arm64 ones).
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Secrets and local state
|
||||||
.env
|
.env
|
||||||
|
.env.local
|
||||||
.git
|
.git
|
||||||
src
|
|
||||||
|
# Local data dirs (Redis cache file, setup-state, etc.)
|
||||||
|
data
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -37,3 +37,8 @@ lerna-debug.log*
|
|||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# Widget config — instance-specific, auto-generated on first boot.
|
||||||
|
# Each environment mints its own HMAC-signed site key.
|
||||||
|
data/widget.json
|
||||||
|
data/widget-backups/
|
||||||
|
|||||||
57
Dockerfile
57
Dockerfile
@@ -1,7 +1,58 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
#
|
||||||
|
# Multi-stage build for the helix-engage sidecar.
|
||||||
|
#
|
||||||
|
# Why multi-stage instead of "build on host, COPY dist + node_modules"?
|
||||||
|
# The host (developer Mac, CI runner) is rarely the same architecture
|
||||||
|
# as the target (linux/amd64 EC2 / VPS). Copying a host-built
|
||||||
|
# node_modules brings darwin-arm64 native bindings (sharp, livekit,
|
||||||
|
# fsevents, etc.) into the runtime image, which crash on first import.
|
||||||
|
# This Dockerfile rebuilds inside the target-platform container so
|
||||||
|
# native bindings are downloaded/compiled for the right arch.
|
||||||
|
#
|
||||||
|
# The build stage runs `npm ci` + `nest build`, then `npm prune` to
|
||||||
|
# strip dev deps. The runtime stage carries forward only `dist/`,
|
||||||
|
# the pruned `node_modules/`, and `package.json`.
|
||||||
|
|
||||||
|
# --- Builder stage ----------------------------------------------------------
|
||||||
|
FROM node:22-slim AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Build deps for any native modules whose prebuilt binaries miss the
|
||||||
|
# target arch. Kept minimal — node:22-slim already ships most of what's
|
||||||
|
# needed for the deps in this project, but python/make/g++ are the
|
||||||
|
# canonical "I might need to gyp-rebuild" trio.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
python3 \
|
||||||
|
make \
|
||||||
|
g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Lockfile-only install first so this layer caches when only source
|
||||||
|
# changes — much faster repeat builds.
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --no-audit --no-fund --loglevel=verbose
|
||||||
|
|
||||||
|
# Source + build config
|
||||||
|
COPY tsconfig.json tsconfig.build.json nest-cli.json ./
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Strip dev dependencies so the runtime image stays small.
|
||||||
|
RUN npm prune --omit=dev
|
||||||
|
|
||||||
|
|
||||||
|
# --- Runtime stage ----------------------------------------------------------
|
||||||
FROM node:22-slim
|
FROM node:22-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY dist ./dist
|
|
||||||
COPY node_modules ./node_modules
|
# Bring across only what the runtime needs. Source, dev deps, build
|
||||||
COPY package.json ./
|
# tooling all stay in the builder stage and get discarded.
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/package.json ./
|
||||||
|
|
||||||
EXPOSE 4100
|
EXPOSE 4100
|
||||||
CMD ["node", "dist/main.js"]
|
CMD ["node", "dist/main.js"]
|
||||||
|
|||||||
50
data/theme-backups/theme-2026-04-02T09-33-40-460Z.json
Normal file
50
data/theme-backups/theme-2026-04-02T09-33-40-460Z.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"brand": {
|
||||||
|
"name": "Helix Engage",
|
||||||
|
"hospitalName": "Global Hospital",
|
||||||
|
"logo": "/helix-logo.png",
|
||||||
|
"favicon": "/favicon.ico"
|
||||||
|
},
|
||||||
|
"colors": {
|
||||||
|
"brand": {
|
||||||
|
"25": "rgb(239 246 255)",
|
||||||
|
"50": "rgb(219 234 254)",
|
||||||
|
"100": "rgb(191 219 254)",
|
||||||
|
"200": "rgb(147 197 253)",
|
||||||
|
"300": "rgb(96 165 250)",
|
||||||
|
"400": "rgb(59 130 246)",
|
||||||
|
"500": "rgb(37 99 235)",
|
||||||
|
"600": "rgb(29 78 216)",
|
||||||
|
"700": "rgb(30 64 175)",
|
||||||
|
"800": "rgb(30 58 138)",
|
||||||
|
"900": "rgb(23 37 84)",
|
||||||
|
"950": "rgb(15 23 42)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"typography": {
|
||||||
|
"body": "'Satoshi', var(--font-inter, 'Inter'), -apple-system, 'Segoe UI', Roboto, Arial, sans-serif",
|
||||||
|
"display": "'General Sans', var(--font-inter, 'Inter'), -apple-system, 'Segoe UI', Roboto, Arial, sans-serif"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Sign in to Helix Engage",
|
||||||
|
"subtitle": "Global Hospital",
|
||||||
|
"showGoogleSignIn": true,
|
||||||
|
"showForgotPassword": true,
|
||||||
|
"poweredBy": {
|
||||||
|
"label": "Powered by F0rty2.ai",
|
||||||
|
"url": "https://f0rty2.ai"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sidebar": {
|
||||||
|
"title": "Helix Engage",
|
||||||
|
"subtitle": "Global Hospital · {role}"
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"quickActions": [
|
||||||
|
{ "label": "Doctor availability", "prompt": "What doctors are available and what are their visiting hours?" },
|
||||||
|
{ "label": "Clinic timings", "prompt": "What are the clinic locations and timings?" },
|
||||||
|
{ "label": "Patient history", "prompt": "Can you summarize this patient's history?" },
|
||||||
|
{ "label": "Treatment packages", "prompt": "What treatment packages are available?" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
62
data/theme-backups/theme-2026-04-02T09-34-04-404Z.json
Normal file
62
data/theme-backups/theme-2026-04-02T09-34-04-404Z.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"brand": {
|
||||||
|
"name": "Test",
|
||||||
|
"hospitalName": "Global Hospital",
|
||||||
|
"logo": "/helix-logo.png",
|
||||||
|
"favicon": "/favicon.ico"
|
||||||
|
},
|
||||||
|
"colors": {
|
||||||
|
"brand": {
|
||||||
|
"25": "rgb(239 246 255)",
|
||||||
|
"50": "rgb(219 234 254)",
|
||||||
|
"100": "rgb(191 219 254)",
|
||||||
|
"200": "rgb(147 197 253)",
|
||||||
|
"300": "rgb(96 165 250)",
|
||||||
|
"400": "rgb(59 130 246)",
|
||||||
|
"500": "rgb(37 99 235)",
|
||||||
|
"600": "rgb(29 78 216)",
|
||||||
|
"700": "rgb(30 64 175)",
|
||||||
|
"800": "rgb(30 58 138)",
|
||||||
|
"900": "rgb(23 37 84)",
|
||||||
|
"950": "rgb(15 23 42)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"typography": {
|
||||||
|
"body": "'Satoshi', var(--font-inter, 'Inter'), -apple-system, 'Segoe UI', Roboto, Arial, sans-serif",
|
||||||
|
"display": "'General Sans', var(--font-inter, 'Inter'), -apple-system, 'Segoe UI', Roboto, Arial, sans-serif"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Sign in to Helix Engage",
|
||||||
|
"subtitle": "Global Hospital",
|
||||||
|
"showGoogleSignIn": true,
|
||||||
|
"showForgotPassword": true,
|
||||||
|
"poweredBy": {
|
||||||
|
"label": "Powered by F0rty2.ai",
|
||||||
|
"url": "https://f0rty2.ai"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sidebar": {
|
||||||
|
"title": "Helix Engage",
|
||||||
|
"subtitle": "Global Hospital · {role}"
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"quickActions": [
|
||||||
|
{
|
||||||
|
"label": "Doctor availability",
|
||||||
|
"prompt": "What doctors are available and what are their visiting hours?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Clinic timings",
|
||||||
|
"prompt": "What are the clinic locations and timings?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Patient history",
|
||||||
|
"prompt": "Can you summarize this patient's history?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Treatment packages",
|
||||||
|
"prompt": "What treatment packages are available?"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
62
data/theme-backups/theme-2026-04-02T09-41-45-744Z.json
Normal file
62
data/theme-backups/theme-2026-04-02T09-41-45-744Z.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"brand": {
|
||||||
|
"name": "Helix Engage",
|
||||||
|
"hospitalName": "Global Hospital",
|
||||||
|
"logo": "/helix-logo.png",
|
||||||
|
"favicon": "/favicon.ico"
|
||||||
|
},
|
||||||
|
"colors": {
|
||||||
|
"brand": {
|
||||||
|
"25": "rgb(239 246 255)",
|
||||||
|
"50": "rgb(219 234 254)",
|
||||||
|
"100": "rgb(191 219 254)",
|
||||||
|
"200": "rgb(147 197 253)",
|
||||||
|
"300": "rgb(96 165 250)",
|
||||||
|
"400": "rgb(59 130 246)",
|
||||||
|
"500": "rgb(37 99 235)",
|
||||||
|
"600": "rgb(29 78 216)",
|
||||||
|
"700": "rgb(30 64 175)",
|
||||||
|
"800": "rgb(30 58 138)",
|
||||||
|
"900": "rgb(23 37 84)",
|
||||||
|
"950": "rgb(15 23 42)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"typography": {
|
||||||
|
"body": "'Satoshi', var(--font-inter, 'Inter'), -apple-system, 'Segoe UI', Roboto, Arial, sans-serif",
|
||||||
|
"display": "'General Sans', var(--font-inter, 'Inter'), -apple-system, 'Segoe UI', Roboto, Arial, sans-serif"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Sign in to Helix Engage",
|
||||||
|
"subtitle": "Global Hospital",
|
||||||
|
"showGoogleSignIn": true,
|
||||||
|
"showForgotPassword": true,
|
||||||
|
"poweredBy": {
|
||||||
|
"label": "Powered by F0rty2.ai",
|
||||||
|
"url": "https://f0rty2.ai"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sidebar": {
|
||||||
|
"title": "Helix Engage",
|
||||||
|
"subtitle": "Global Hospital · {role}"
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"quickActions": [
|
||||||
|
{
|
||||||
|
"label": "Doctor availability",
|
||||||
|
"prompt": "What doctors are available and what are their visiting hours?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Clinic timings",
|
||||||
|
"prompt": "What are the clinic locations and timings?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Patient history",
|
||||||
|
"prompt": "Can you summarize this patient's history?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Treatment packages",
|
||||||
|
"prompt": "What treatment packages are available?"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
62
data/theme-backups/theme-2026-04-02T09-42-24-047Z.json
Normal file
62
data/theme-backups/theme-2026-04-02T09-42-24-047Z.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"brand": {
|
||||||
|
"name": "Helix Engage",
|
||||||
|
"hospitalName": "Global Hospital",
|
||||||
|
"logo": "/helix-logo.png",
|
||||||
|
"favicon": "/favicon.ico"
|
||||||
|
},
|
||||||
|
"colors": {
|
||||||
|
"brand": {
|
||||||
|
"25": "rgb(250 245 255)",
|
||||||
|
"50": "rgb(245 235 255)",
|
||||||
|
"100": "rgb(235 215 254)",
|
||||||
|
"200": "rgb(214 187 251)",
|
||||||
|
"300": "rgb(182 146 246)",
|
||||||
|
"400": "rgb(158 119 237)",
|
||||||
|
"500": "rgb(127 86 217)",
|
||||||
|
"600": "rgb(105 65 198)",
|
||||||
|
"700": "rgb(83 56 158)",
|
||||||
|
"800": "rgb(66 48 125)",
|
||||||
|
"900": "rgb(53 40 100)",
|
||||||
|
"950": "rgb(44 28 95)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"typography": {
|
||||||
|
"body": "'Satoshi', var(--font-inter, 'Inter'), -apple-system, 'Segoe UI', Roboto, Arial, sans-serif",
|
||||||
|
"display": "'General Sans', var(--font-inter, 'Inter'), -apple-system, 'Segoe UI', Roboto, Arial, sans-serif"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Sign in to Ramaiah",
|
||||||
|
"subtitle": "Ramaiah Hospital",
|
||||||
|
"showGoogleSignIn": true,
|
||||||
|
"showForgotPassword": true,
|
||||||
|
"poweredBy": {
|
||||||
|
"label": "Powered by F0rty2.ai",
|
||||||
|
"url": "https://f0rty2.ai"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sidebar": {
|
||||||
|
"title": "Ramaiah",
|
||||||
|
"subtitle": "Ramaiah Hospital · {role}"
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"quickActions": [
|
||||||
|
{
|
||||||
|
"label": "Doctor availability",
|
||||||
|
"prompt": "What doctors are available and what are their visiting hours?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Clinic timings",
|
||||||
|
"prompt": "What are the clinic locations and timings?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Patient history",
|
||||||
|
"prompt": "Can you summarize this patient's history?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Treatment packages",
|
||||||
|
"prompt": "What treatment packages are available?"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
62
data/theme-backups/theme-2026-04-02T09-43-19-186Z.json
Normal file
62
data/theme-backups/theme-2026-04-02T09-43-19-186Z.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"brand": {
|
||||||
|
"name": "Helix Engage",
|
||||||
|
"hospitalName": "Global Hospital",
|
||||||
|
"logo": "/helix-logo.png",
|
||||||
|
"favicon": "/favicon.ico"
|
||||||
|
},
|
||||||
|
"colors": {
|
||||||
|
"brand": {
|
||||||
|
"25": "rgb(248 250 252)",
|
||||||
|
"50": "rgb(241 245 249)",
|
||||||
|
"100": "rgb(226 232 240)",
|
||||||
|
"200": "rgb(203 213 225)",
|
||||||
|
"300": "rgb(148 163 184)",
|
||||||
|
"400": "rgb(100 116 139)",
|
||||||
|
"500": "rgb(71 85 105)",
|
||||||
|
"600": "rgb(47 64 89)",
|
||||||
|
"700": "rgb(37 49 72)",
|
||||||
|
"800": "rgb(30 41 59)",
|
||||||
|
"900": "rgb(15 23 42)",
|
||||||
|
"950": "rgb(2 6 23)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"typography": {
|
||||||
|
"body": "'Plus Jakarta Sans', 'Inter', sans-serif",
|
||||||
|
"display": "'Plus Jakarta Sans', 'Inter', sans-serif"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Sign in to Ramaiah",
|
||||||
|
"subtitle": "Ramaiah Hospital",
|
||||||
|
"showGoogleSignIn": true,
|
||||||
|
"showForgotPassword": true,
|
||||||
|
"poweredBy": {
|
||||||
|
"label": "Powered by F0rty2.ai",
|
||||||
|
"url": "https://f0rty2.ai"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sidebar": {
|
||||||
|
"title": "Ramaiah",
|
||||||
|
"subtitle": "Ramaiah Hospital · {role}"
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"quickActions": [
|
||||||
|
{
|
||||||
|
"label": "Doctor availability",
|
||||||
|
"prompt": "What doctors are available and what are their visiting hours?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Clinic timings",
|
||||||
|
"prompt": "What are the clinic locations and timings?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Patient history",
|
||||||
|
"prompt": "Can you summarize this patient's history?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Treatment packages",
|
||||||
|
"prompt": "What treatment packages are available?"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
62
data/theme-backups/theme-2026-04-02T09-53-00-903Z.json
Normal file
62
data/theme-backups/theme-2026-04-02T09-53-00-903Z.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"brand": {
|
||||||
|
"name": "Helix Engage",
|
||||||
|
"hospitalName": "Global Hospital",
|
||||||
|
"logo": "/helix-logo.png",
|
||||||
|
"favicon": "/favicon.ico"
|
||||||
|
},
|
||||||
|
"colors": {
|
||||||
|
"brand": {
|
||||||
|
"25": "rgb(248 250 252)",
|
||||||
|
"50": "rgb(241 245 249)",
|
||||||
|
"100": "rgb(226 232 240)",
|
||||||
|
"200": "rgb(203 213 225)",
|
||||||
|
"300": "rgb(148 163 184)",
|
||||||
|
"400": "rgb(100 116 139)",
|
||||||
|
"500": "rgb(71 85 105)",
|
||||||
|
"600": "rgb(47 64 89)",
|
||||||
|
"700": "rgb(37 49 72)",
|
||||||
|
"800": "rgb(30 41 59)",
|
||||||
|
"900": "rgb(15 23 42)",
|
||||||
|
"950": "rgb(2 6 23)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"typography": {
|
||||||
|
"body": "'Plus Jakarta Sans', 'Inter', sans-serif",
|
||||||
|
"display": "'Plus Jakarta Sans', 'Inter', sans-serif"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Sign in to Ramaiah",
|
||||||
|
"subtitle": "Ramaiah Hospital",
|
||||||
|
"showGoogleSignIn": false,
|
||||||
|
"showForgotPassword": true,
|
||||||
|
"poweredBy": {
|
||||||
|
"label": "Powered by F0rty2.ai",
|
||||||
|
"url": "https://f0rty2.ai"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sidebar": {
|
||||||
|
"title": "Ramaiah",
|
||||||
|
"subtitle": "Ramaiah Hospital · {role}"
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"quickActions": [
|
||||||
|
{
|
||||||
|
"label": "Doctor availability",
|
||||||
|
"prompt": "What doctors are available and what are their visiting hours?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Clinic timings",
|
||||||
|
"prompt": "What are the clinic locations and timings?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Patient history",
|
||||||
|
"prompt": "Can you summarize this patient's history?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Treatment packages",
|
||||||
|
"prompt": "What treatment packages are available?"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
62
data/theme-backups/theme-2026-04-02T10-00-48-735Z.json
Normal file
62
data/theme-backups/theme-2026-04-02T10-00-48-735Z.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"brand": {
|
||||||
|
"name": "Helix Engage",
|
||||||
|
"hospitalName": "Global Hospital",
|
||||||
|
"logo": "/helix-logo.png",
|
||||||
|
"favicon": "/favicon.ico"
|
||||||
|
},
|
||||||
|
"colors": {
|
||||||
|
"brand": {
|
||||||
|
"25": "rgb(240 253 250)",
|
||||||
|
"50": "rgb(204 251 241)",
|
||||||
|
"100": "rgb(153 246 228)",
|
||||||
|
"200": "rgb(94 234 212)",
|
||||||
|
"300": "rgb(45 212 191)",
|
||||||
|
"400": "rgb(20 184 166)",
|
||||||
|
"500": "rgb(13 148 136)",
|
||||||
|
"600": "rgb(15 118 110)",
|
||||||
|
"700": "rgb(17 94 89)",
|
||||||
|
"800": "rgb(19 78 74)",
|
||||||
|
"900": "rgb(17 63 61)",
|
||||||
|
"950": "rgb(4 47 46)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"typography": {
|
||||||
|
"body": "'Plus Jakarta Sans', 'Inter', sans-serif",
|
||||||
|
"display": "'Plus Jakarta Sans', 'Inter', sans-serif"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Sign in to Ramaiah",
|
||||||
|
"subtitle": "Ramaiah Hospital",
|
||||||
|
"showGoogleSignIn": false,
|
||||||
|
"showForgotPassword": true,
|
||||||
|
"poweredBy": {
|
||||||
|
"label": "Powered by F0rty2.ai",
|
||||||
|
"url": "https://f0rty2.ai"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sidebar": {
|
||||||
|
"title": "Ramaiah",
|
||||||
|
"subtitle": "Ramaiah Hospital · {role}"
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"quickActions": [
|
||||||
|
{
|
||||||
|
"label": "Doctor availability",
|
||||||
|
"prompt": "What doctors are available and what are their visiting hours?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Clinic timings",
|
||||||
|
"prompt": "What are the clinic locations and timings?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Patient history",
|
||||||
|
"prompt": "Can you summarize this patient's history?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Treatment packages",
|
||||||
|
"prompt": "What treatment packages are available?"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
62
data/theme-backups/theme-2026-04-02T10-19-29-559Z.json
Normal file
62
data/theme-backups/theme-2026-04-02T10-19-29-559Z.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"brand": {
|
||||||
|
"name": "Helix Engage",
|
||||||
|
"hospitalName": "Global Hospital",
|
||||||
|
"logo": "/helix-logo.png",
|
||||||
|
"favicon": "/favicon.ico"
|
||||||
|
},
|
||||||
|
"colors": {
|
||||||
|
"brand": {
|
||||||
|
"25": "rgb(240 253 250)",
|
||||||
|
"50": "rgb(204 251 241)",
|
||||||
|
"100": "rgb(153 246 228)",
|
||||||
|
"200": "rgb(94 234 212)",
|
||||||
|
"300": "rgb(45 212 191)",
|
||||||
|
"400": "rgb(20 184 166)",
|
||||||
|
"500": "rgb(13 148 136)",
|
||||||
|
"600": "rgb(15 118 110)",
|
||||||
|
"700": "rgb(17 94 89)",
|
||||||
|
"800": "rgb(19 78 74)",
|
||||||
|
"900": "rgb(17 63 61)",
|
||||||
|
"950": "rgb(4 47 46)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"typography": {
|
||||||
|
"body": "'Satoshi', 'Inter', -apple-system, sans-serif",
|
||||||
|
"display": "'Satoshi', 'Inter', -apple-system, sans-serif"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Sign in to Ramaiah",
|
||||||
|
"subtitle": "Ramaiah Hospital",
|
||||||
|
"showGoogleSignIn": false,
|
||||||
|
"showForgotPassword": true,
|
||||||
|
"poweredBy": {
|
||||||
|
"label": "Powered by F0rty2.ai",
|
||||||
|
"url": "https://f0rty2.ai"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sidebar": {
|
||||||
|
"title": "Ramaiah",
|
||||||
|
"subtitle": "Ramaiah Hospital · {role}"
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"quickActions": [
|
||||||
|
{
|
||||||
|
"label": "Doctor availability",
|
||||||
|
"prompt": "What doctors are available and what are their visiting hours?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Clinic timings",
|
||||||
|
"prompt": "What are the clinic locations and timings?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Patient history",
|
||||||
|
"prompt": "Can you summarize this patient's history?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Treatment packages",
|
||||||
|
"prompt": "What treatment packages are available?"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
64
data/theme-backups/theme-2026-04-02T10-19-35-284Z.json
Normal file
64
data/theme-backups/theme-2026-04-02T10-19-35-284Z.json
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"brand": {
|
||||||
|
"name": "Helix Engage",
|
||||||
|
"hospitalName": "Global Hospital",
|
||||||
|
"logo": "/helix-logo.png",
|
||||||
|
"favicon": "/favicon.ico"
|
||||||
|
},
|
||||||
|
"colors": {
|
||||||
|
"brand": {
|
||||||
|
"25": "rgb(249 252 243)",
|
||||||
|
"50": "rgb(244 249 231)",
|
||||||
|
"100": "rgb(235 244 210)",
|
||||||
|
"200": "rgb(224 247 161)",
|
||||||
|
"300": "rgb(206 243 104)",
|
||||||
|
"400": "rgb(195 255 31)",
|
||||||
|
"500": "rgb(172 235 0)",
|
||||||
|
"600": "rgb(142 194 0)",
|
||||||
|
"700": "rgb(116 158 0)",
|
||||||
|
"800": "rgb(97 133 0)",
|
||||||
|
"900": "rgb(75 102 0)",
|
||||||
|
"950": "rgb(49 66 0)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"typography": {
|
||||||
|
"body": "'Satoshi', 'Inter', -apple-system, sans-serif",
|
||||||
|
"display": "'Satoshi', 'Inter', -apple-system, sans-serif"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Sign in to Ramaiah",
|
||||||
|
"subtitle": "Ramaiah Hospital",
|
||||||
|
"showGoogleSignIn": false,
|
||||||
|
"showForgotPassword": true,
|
||||||
|
"poweredBy": {
|
||||||
|
"label": "Powered by F0rty2.ai",
|
||||||
|
"url": "https://f0rty2.ai"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sidebar": {
|
||||||
|
"title": "Ramaiah",
|
||||||
|
"subtitle": "Ramaiah Hospital · {role}"
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"quickActions": [
|
||||||
|
{
|
||||||
|
"label": "Doctor availability",
|
||||||
|
"prompt": "What doctors are available and what are their visiting hours?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Clinic timings",
|
||||||
|
"prompt": "What are the clinic locations and timings?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Patient history",
|
||||||
|
"prompt": "Can you summarize this patient's history?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Treatment packages",
|
||||||
|
"prompt": "What treatment packages are available?"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"version": 1,
|
||||||
|
"updatedAt": "2026-04-02T10:19:29.559Z"
|
||||||
|
}
|
||||||
64
data/theme.json
Normal file
64
data/theme.json
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"brand": {
|
||||||
|
"name": "Helix Engage",
|
||||||
|
"hospitalName": "Global Hospital",
|
||||||
|
"logo": "/helix-logo.png",
|
||||||
|
"favicon": "/favicon.ico"
|
||||||
|
},
|
||||||
|
"colors": {
|
||||||
|
"brand": {
|
||||||
|
"25": "rgb(250 245 255)",
|
||||||
|
"50": "rgb(245 235 255)",
|
||||||
|
"100": "rgb(235 215 254)",
|
||||||
|
"200": "rgb(214 187 251)",
|
||||||
|
"300": "rgb(182 146 246)",
|
||||||
|
"400": "rgb(158 119 237)",
|
||||||
|
"500": "rgb(127 86 217)",
|
||||||
|
"600": "rgb(105 65 198)",
|
||||||
|
"700": "rgb(83 56 158)",
|
||||||
|
"800": "rgb(66 48 125)",
|
||||||
|
"900": "rgb(53 40 100)",
|
||||||
|
"950": "rgb(44 28 95)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"typography": {
|
||||||
|
"body": "'Satoshi', 'Inter', -apple-system, sans-serif",
|
||||||
|
"display": "'Satoshi', 'Inter', -apple-system, sans-serif"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Sign in to Ramaiah",
|
||||||
|
"subtitle": "Ramaiah Hospital",
|
||||||
|
"showGoogleSignIn": false,
|
||||||
|
"showForgotPassword": true,
|
||||||
|
"poweredBy": {
|
||||||
|
"label": "Powered by F0rty2.ai",
|
||||||
|
"url": "https://f0rty2.ai"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sidebar": {
|
||||||
|
"title": "Ramaiah",
|
||||||
|
"subtitle": "Ramaiah Hospital · {role}"
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"quickActions": [
|
||||||
|
{
|
||||||
|
"label": "Doctor availability",
|
||||||
|
"prompt": "What doctors are available and what are their visiting hours?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Clinic timings",
|
||||||
|
"prompt": "What are the clinic locations and timings?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Patient history",
|
||||||
|
"prompt": "Can you summarize this patient's history?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Treatment packages",
|
||||||
|
"prompt": "What treatment packages are available?"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"version": 2,
|
||||||
|
"updatedAt": "2026-04-02T10:19:35.284Z"
|
||||||
|
}
|
||||||
5884
package-lock.json
generated
5884
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,9 @@
|
|||||||
"@ai-sdk/anthropic": "^3.0.58",
|
"@ai-sdk/anthropic": "^3.0.58",
|
||||||
"@ai-sdk/openai": "^3.0.41",
|
"@ai-sdk/openai": "^3.0.41",
|
||||||
"@deepgram/sdk": "^5.0.0",
|
"@deepgram/sdk": "^5.0.0",
|
||||||
|
"@livekit/agents": "^1.2.1",
|
||||||
|
"@livekit/agents-plugin-google": "^1.2.1",
|
||||||
|
"@livekit/agents-plugin-silero": "^1.2.1",
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/config": "^4.0.3",
|
"@nestjs/config": "^4.0.3",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
@@ -31,9 +34,13 @@
|
|||||||
"@nestjs/websockets": "^11.1.17",
|
"@nestjs/websockets": "^11.1.17",
|
||||||
"ai": "^6.0.116",
|
"ai": "^6.0.116",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
|
"ioredis": "^5.10.1",
|
||||||
|
"json-rules-engine": "^6.6.0",
|
||||||
|
"kafkajs": "^2.2.4",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"socket.io": "^4.8.3"
|
"socket.io": "^4.8.3",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
|
|||||||
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, ',
|
||||||
|
};
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import { Controller, Post, Body, Headers, HttpException, Logger } from '@nestjs/common';
|
import { Controller, Post, Body, Headers, Req, Res, HttpException, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { generateText, tool, stepCountIs } from 'ai';
|
import type { Request, Response } from 'express';
|
||||||
|
import { generateText, streamText, tool, stepCountIs } from 'ai';
|
||||||
import type { LanguageModel } from 'ai';
|
import type { LanguageModel } from 'ai';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
import { createAiModel, isAiConfigured } from './ai-provider';
|
import { createAiModel, isAiConfigured } from './ai-provider';
|
||||||
|
import { AiConfigService } from '../config/ai-config.service';
|
||||||
|
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../shared/doctor-utils';
|
||||||
|
|
||||||
type ChatRequest = {
|
type ChatRequest = {
|
||||||
message: string;
|
message: string;
|
||||||
@@ -22,14 +25,19 @@ export class AiChatController {
|
|||||||
constructor(
|
constructor(
|
||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
private platform: PlatformGraphqlService,
|
private platform: PlatformGraphqlService,
|
||||||
|
private aiConfig: AiConfigService,
|
||||||
) {
|
) {
|
||||||
this.aiModel = createAiModel(config);
|
const cfg = aiConfig.getConfig();
|
||||||
|
this.aiModel = createAiModel({
|
||||||
|
provider: cfg.provider,
|
||||||
|
model: cfg.model,
|
||||||
|
anthropicApiKey: config.get<string>('ai.anthropicApiKey'),
|
||||||
|
openaiApiKey: config.get<string>('ai.openaiApiKey'),
|
||||||
|
});
|
||||||
if (!this.aiModel) {
|
if (!this.aiModel) {
|
||||||
this.logger.warn('AI not configured — chat uses fallback');
|
this.logger.warn('AI not configured — chat uses fallback');
|
||||||
} else {
|
} else {
|
||||||
const provider = config.get<string>('ai.provider') ?? 'openai';
|
this.logger.log(`AI configured: ${cfg.provider}/${cfg.model}`);
|
||||||
const model = config.get<string>('ai.model') ?? 'gpt-4o-mini';
|
|
||||||
this.logger.log(`AI configured: ${provider}/${model}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,9 +69,442 @@ export class AiChatController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('stream')
|
||||||
|
async stream(@Req() req: Request, @Res() res: Response, @Headers('authorization') auth: string) {
|
||||||
|
if (!auth) throw new HttpException('Authorization required', 401);
|
||||||
|
|
||||||
|
const body = req.body;
|
||||||
|
const messages = body.messages ?? [];
|
||||||
|
if (!messages.length) throw new HttpException('messages required', 400);
|
||||||
|
|
||||||
|
if (!this.aiModel) {
|
||||||
|
res.status(500).json({ error: 'AI not configured' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = body.context;
|
||||||
|
let systemPrompt: string;
|
||||||
|
|
||||||
|
// Rules engine context — use rules-specific system prompt
|
||||||
|
if (ctx?.type === 'rules-engine') {
|
||||||
|
systemPrompt = this.buildRulesSystemPrompt(ctx.currentConfig);
|
||||||
|
} else if (ctx?.type === 'supervisor') {
|
||||||
|
systemPrompt = this.buildSupervisorSystemPrompt();
|
||||||
|
} else {
|
||||||
|
const kb = await this.buildKnowledgeBase(auth);
|
||||||
|
systemPrompt = this.buildSystemPrompt(kb);
|
||||||
|
|
||||||
|
// Inject caller context so the AI knows who is selected
|
||||||
|
if (ctx) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (ctx.leadName) parts.push(`Currently viewing/talking to: ${ctx.leadName}`);
|
||||||
|
if (ctx.callerPhone) parts.push(`Phone: ${ctx.callerPhone}`);
|
||||||
|
if (ctx.leadId) parts.push(`Lead ID: ${ctx.leadId}`);
|
||||||
|
if (parts.length) {
|
||||||
|
systemPrompt += `\n\nCURRENT CONTEXT:\n${parts.join('\n')}\nUse this context to answer questions about "this patient" or "this caller" without asking for their name.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformService = this.platform;
|
||||||
|
const isSupervisor = ctx?.type === 'supervisor';
|
||||||
|
|
||||||
|
// Supervisor tools — agent performance, campaign stats, team metrics
|
||||||
|
const supervisorTools = {
|
||||||
|
get_agent_performance: tool({
|
||||||
|
description: 'Get performance metrics for all agents or a specific agent. Returns call counts, conversion rates, idle time, NPS scores.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
agentName: z.string().optional().describe('Agent name to look up. Leave empty for all agents.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ agentName }) => {
|
||||||
|
const [callsData, leadsData, agentsData, followUpsData] = await Promise.all([
|
||||||
|
platformService.queryWithAuth<any>(
|
||||||
|
`{ calls(first: 200, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { id direction callStatus agentName startedAt durationSec disposition } } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
),
|
||||||
|
platformService.queryWithAuth<any>(
|
||||||
|
`{ leads(first: 200) { edges { node { id assignedAgent status } } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
),
|
||||||
|
platformService.queryWithAuth<any>(
|
||||||
|
// 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>(
|
||||||
|
`{ followUps(first: 100) { edges { node { id assignedAgent status } } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const calls = callsData.calls.edges.map((e: any) => e.node);
|
||||||
|
const leads = leadsData.leads.edges.map((e: any) => e.node);
|
||||||
|
const agents = agentsData.agents.edges.map((e: any) => e.node);
|
||||||
|
const followUps = followUpsData.followUps.edges.map((e: any) => e.node);
|
||||||
|
|
||||||
|
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 totalCalls = agentCalls.length;
|
||||||
|
const missed = agentCalls.filter((c: any) => c.callStatus === 'MISSED').length;
|
||||||
|
const completed = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
|
||||||
|
const apptBooked = agentCalls.filter((c: any) => c.disposition === 'APPOINTMENT_BOOKED').length;
|
||||||
|
const agentLeads = leads.filter((l: any) => l.assignedAgent === agent.name);
|
||||||
|
const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === agent.name);
|
||||||
|
const pendingFollowUps = agentFollowUps.filter((f: any) => f.status === 'PENDING' || f.status === 'OVERDUE').length;
|
||||||
|
const conversionRate = totalCalls > 0 ? Math.round((apptBooked / totalCalls) * 100) : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: agent.name,
|
||||||
|
totalCalls,
|
||||||
|
completed,
|
||||||
|
missed,
|
||||||
|
appointmentsBooked: apptBooked,
|
||||||
|
conversionRate: `${conversionRate}%`,
|
||||||
|
assignedLeads: agentLeads.length,
|
||||||
|
pendingFollowUps,
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { agents: agentMetrics, totalAgents: agentMetrics.length };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
get_campaign_stats: tool({
|
||||||
|
description: 'Get campaign performance stats — lead counts, conversion rates, sources.',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
execute: async () => {
|
||||||
|
const [campaignsData, leadsData] = await Promise.all([
|
||||||
|
platformService.queryWithAuth<any>(
|
||||||
|
`{ campaigns(first: 20) { edges { node { id campaignName campaignStatus platform leadCount convertedCount budget { amountMicros } } } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
),
|
||||||
|
platformService.queryWithAuth<any>(
|
||||||
|
`{ leads(first: 200) { edges { node { id campaignId status } } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const campaigns = campaignsData.campaigns.edges.map((e: any) => e.node);
|
||||||
|
const leads = leadsData.leads.edges.map((e: any) => e.node);
|
||||||
|
|
||||||
|
return {
|
||||||
|
campaigns: campaigns.map((c: any) => {
|
||||||
|
const campaignLeads = leads.filter((l: any) => l.campaignId === c.id);
|
||||||
|
const converted = campaignLeads.filter((l: any) => l.status === 'CONVERTED' || l.status === 'APPOINTMENT_SET').length;
|
||||||
|
return {
|
||||||
|
name: c.campaignName,
|
||||||
|
status: c.campaignStatus,
|
||||||
|
platform: c.platform,
|
||||||
|
totalLeads: campaignLeads.length,
|
||||||
|
converted,
|
||||||
|
conversionRate: campaignLeads.length > 0 ? `${Math.round((converted / campaignLeads.length) * 100)}%` : '0%',
|
||||||
|
budget: c.budget ? `₹${c.budget.amountMicros / 1_000_000}` : null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
get_call_summary: tool({
|
||||||
|
description: 'Get aggregate call statistics — total calls, inbound/outbound split, missed call rate, average duration, disposition breakdown.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
period: z.string().optional().describe('Period: "today", "week", "month". Defaults to "week".'),
|
||||||
|
}),
|
||||||
|
execute: async ({ period }) => {
|
||||||
|
const data = await platformService.queryWithAuth<any>(
|
||||||
|
`{ calls(first: 500, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { id direction callStatus agentName startedAt durationSec disposition } } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
);
|
||||||
|
const allCalls = data.calls.edges.map((e: any) => e.node);
|
||||||
|
|
||||||
|
// Filter by period
|
||||||
|
const now = new Date();
|
||||||
|
const start = new Date(now);
|
||||||
|
if (period === 'today') start.setHours(0, 0, 0, 0);
|
||||||
|
else if (period === 'month') start.setDate(start.getDate() - 30);
|
||||||
|
else start.setDate(start.getDate() - 7); // default week
|
||||||
|
|
||||||
|
const calls = allCalls.filter((c: any) => c.startedAt && new Date(c.startedAt) >= start);
|
||||||
|
|
||||||
|
const total = calls.length;
|
||||||
|
const inbound = calls.filter((c: any) => c.direction === 'INBOUND').length;
|
||||||
|
const outbound = total - inbound;
|
||||||
|
const missed = calls.filter((c: any) => c.callStatus === 'MISSED').length;
|
||||||
|
const completed = calls.filter((c: any) => c.callStatus === 'COMPLETED').length;
|
||||||
|
const totalDuration = calls.reduce((sum: number, c: any) => sum + (c.durationSec ?? 0), 0);
|
||||||
|
const avgDuration = completed > 0 ? Math.round(totalDuration / completed) : 0;
|
||||||
|
|
||||||
|
const dispositions: Record<string, number> = {};
|
||||||
|
for (const c of calls) {
|
||||||
|
if (c.disposition) dispositions[c.disposition] = (dispositions[c.disposition] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
period: period ?? 'week',
|
||||||
|
total,
|
||||||
|
inbound,
|
||||||
|
outbound,
|
||||||
|
missed,
|
||||||
|
completed,
|
||||||
|
missedRate: total > 0 ? `${Math.round((missed / total) * 100)}%` : '0%',
|
||||||
|
avgDurationSeconds: avgDuration,
|
||||||
|
dispositions,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
get_sla_breaches: tool({
|
||||||
|
description: 'Get calls/leads that have breached their SLA — items that were not handled within the expected timeframe.',
|
||||||
|
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 } } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
);
|
||||||
|
const breached = data.calls.edges
|
||||||
|
.map((e: any) => e.node)
|
||||||
|
.filter((c: any) => (c.sla ?? 0) > 100);
|
||||||
|
|
||||||
|
return {
|
||||||
|
breachedCount: breached.length,
|
||||||
|
items: breached.map((c: any) => ({
|
||||||
|
id: c.id,
|
||||||
|
phone: c.callerNumber?.primaryPhoneNumber ?? 'Unknown',
|
||||||
|
slaPercent: c.sla,
|
||||||
|
missedAt: c.startedAt,
|
||||||
|
agent: c.agentName,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Agent tools — patient lookup, appointments, doctors
|
||||||
|
const agentTools = {
|
||||||
|
lookup_patient: tool({
|
||||||
|
description: 'Search for a patient/lead by phone number or name. Returns their profile, lead status, AI summary.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
phone: z.string().optional().describe('Phone number to search'),
|
||||||
|
name: z.string().optional().describe('Patient/lead name to search'),
|
||||||
|
}),
|
||||||
|
execute: async ({ phone, name }) => {
|
||||||
|
const data = await platformService.queryWithAuth<any>(
|
||||||
|
`{ leads(first: 50) { edges { node {
|
||||||
|
id name contactName { firstName lastName }
|
||||||
|
contactPhone { primaryPhoneNumber }
|
||||||
|
source status interestedService
|
||||||
|
contactAttempts lastContacted
|
||||||
|
aiSummary aiSuggestedAction patientId
|
||||||
|
} } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
);
|
||||||
|
const leads = data.leads.edges.map((e: any) => e.node);
|
||||||
|
const phoneClean = (phone ?? '').replace(/\D/g, '');
|
||||||
|
const nameClean = (name ?? '').toLowerCase();
|
||||||
|
|
||||||
|
const matched = leads.filter((l: any) => {
|
||||||
|
if (phoneClean) {
|
||||||
|
const lp = (l.contactPhone?.primaryPhoneNumber ?? '').replace(/\D/g, '');
|
||||||
|
if (lp.endsWith(phoneClean) || phoneClean.endsWith(lp)) return true;
|
||||||
|
}
|
||||||
|
if (nameClean) {
|
||||||
|
const fn = `${l.contactName?.firstName ?? ''} ${l.contactName?.lastName ?? ''}`.toLowerCase();
|
||||||
|
if (fn.includes(nameClean)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!matched.length) return { found: false, message: 'No patient/lead found.' };
|
||||||
|
return { found: true, count: matched.length, leads: matched };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
lookup_appointments: tool({
|
||||||
|
description: 'Get appointments for a patient. Returns doctor, department, date, status.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
patientId: z.string().describe('Patient ID'),
|
||||||
|
}),
|
||||||
|
execute: async ({ patientId }) => {
|
||||||
|
const data = await platformService.queryWithAuth<any>(
|
||||||
|
`{ appointments(first: 20, filter: { patientId: { eq: "${patientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||||
|
id scheduledAt status doctorName department reasonForVisit
|
||||||
|
} } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
);
|
||||||
|
return { appointments: data.appointments.edges.map((e: any) => e.node) };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
lookup_doctor: tool({
|
||||||
|
description: 'Get doctor details — schedule, clinic, fees, specialty.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
doctorName: z.string().describe('Doctor name'),
|
||||||
|
}),
|
||||||
|
execute: async ({ doctorName }) => {
|
||||||
|
const data = await platformService.queryWithAuth<any>(
|
||||||
|
`{ doctors(first: 10) { edges { node {
|
||||||
|
id fullName { firstName lastName }
|
||||||
|
department specialty
|
||||||
|
consultationFeeNew { amountMicros currencyCode }
|
||||||
|
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||||
|
} } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
);
|
||||||
|
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+/);
|
||||||
|
const matched = doctors.filter((d: any) => {
|
||||||
|
const fn = (d.fullName?.firstName ?? '').toLowerCase();
|
||||||
|
const ln = (d.fullName?.lastName ?? '').toLowerCase();
|
||||||
|
const full = `${fn} ${ln}`;
|
||||||
|
return searchWords.some(w => w.length > 1 && (fn.includes(w) || ln.includes(w) || full.includes(w)));
|
||||||
|
});
|
||||||
|
this.logger.log(`[TOOL] lookup_doctor: search="${doctorName}" → ${matched.length} results`);
|
||||||
|
if (!matched.length) return { found: false, message: `No doctor matching "${doctorName}". Available: ${doctors.map((d: any) => `Dr. ${d.fullName?.lastName ?? d.fullName?.firstName}`).join(', ')}` };
|
||||||
|
return { found: true, doctors: matched };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
book_appointment: tool({
|
||||||
|
description: 'Book an appointment for a patient. Collect patient name, phone, department, doctor, 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'),
|
||||||
|
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}`);
|
||||||
|
try {
|
||||||
|
const result = await platformService.queryWithAuth<any>(
|
||||||
|
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
name: `AI Booking — ${patientName} (${department})`,
|
||||||
|
scheduledAt,
|
||||||
|
status: 'SCHEDULED',
|
||||||
|
doctorName,
|
||||||
|
department,
|
||||||
|
reasonForVisit: reason,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auth,
|
||||||
|
);
|
||||||
|
const id = result?.createAppointment?.id;
|
||||||
|
if (id) {
|
||||||
|
return { booked: true, appointmentId: id, message: `Appointment booked for ${patientName} with ${doctorName} on ${scheduledAt}. Reference: ${id.substring(0, 8)}` };
|
||||||
|
}
|
||||||
|
return { booked: false, message: 'Appointment creation failed.' };
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[TOOL] book_appointment failed: ${err.message}`);
|
||||||
|
return { booked: false, message: `Failed to book: ${err.message}` };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
create_lead: tool({
|
||||||
|
description: 'Create a new lead/enquiry for a caller who is not an existing patient. Collect name, phone, and interest.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
name: z.string().describe('Caller name'),
|
||||||
|
phoneNumber: z.string().describe('Phone number'),
|
||||||
|
interest: z.string().describe('What they are enquiring about'),
|
||||||
|
}),
|
||||||
|
execute: async ({ name, phoneNumber, interest }) => {
|
||||||
|
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 } }`,
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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}.` };
|
||||||
|
}
|
||||||
|
return { created: false, message: 'Lead creation failed.' };
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[TOOL] create_lead failed: ${err.message}`);
|
||||||
|
return { created: false, message: `Failed: ${err.message}` };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
lookup_call_history: tool({
|
||||||
|
description: 'Get call log for a lead — all inbound/outbound calls with dispositions and durations.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
leadId: z.string().describe('Lead ID'),
|
||||||
|
}),
|
||||||
|
execute: async ({ leadId }) => {
|
||||||
|
const data = await platformService.queryWithAuth<any>(
|
||||||
|
`{ calls(first: 20, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
||||||
|
id direction callStatus agentName startedAt durationSec disposition
|
||||||
|
} } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
);
|
||||||
|
return { calls: data.calls.edges.map((e: any) => e.node) };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = streamText({
|
||||||
|
model: this.aiModel,
|
||||||
|
system: systemPrompt,
|
||||||
|
messages,
|
||||||
|
stopWhen: stepCountIs(5),
|
||||||
|
tools: isSupervisor ? supervisorTools : agentTools,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = result.toTextStreamResponse();
|
||||||
|
res.status(response.status);
|
||||||
|
response.headers.forEach((value, key) => res.setHeader(key, value));
|
||||||
|
if (response.body) {
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const pump = async () => {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) { res.end(); break; }
|
||||||
|
res.write(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
pump().catch(() => res.end());
|
||||||
|
} else {
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async buildKnowledgeBase(auth: string): Promise<string> {
|
private async buildKnowledgeBase(auth: string): Promise<string> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (this.knowledgeBase && now - this.kbLoadedAt < this.kbTtlMs) {
|
if (this.knowledgeBase && now - this.kbLoadedAt < this.kbTtlMs) {
|
||||||
|
this.logger.log(`KB cache hit (${this.knowledgeBase.length} chars, age ${Math.round((now - this.kbLoadedAt) / 1000)}s)`);
|
||||||
return this.knowledgeBase;
|
return this.knowledgeBase;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,17 +525,18 @@ export class AiChatController {
|
|||||||
);
|
);
|
||||||
const clinics = clinicData.clinics.edges.map((e: any) => e.node);
|
const clinics = clinicData.clinics.edges.map((e: any) => e.node);
|
||||||
if (clinics.length) {
|
if (clinics.length) {
|
||||||
sections.push('## Clinics');
|
sections.push('## CLINICS & TIMINGS');
|
||||||
for (const c of clinics) {
|
for (const c of clinics) {
|
||||||
|
const name = c.clinicName ?? c.name;
|
||||||
const addr = c.addressCustom
|
const addr = c.addressCustom
|
||||||
? [c.addressCustom.addressStreet1, c.addressCustom.addressCity].filter(Boolean).join(', ')
|
? [c.addressCustom.addressStreet1, c.addressCustom.addressCity].filter(Boolean).join(', ')
|
||||||
: '';
|
: '';
|
||||||
const hours = [
|
sections.push(`### ${name}`);
|
||||||
c.weekdayHours ? `Mon–Fri ${c.weekdayHours}` : '',
|
if (addr) sections.push(` Address: ${addr}`);
|
||||||
c.saturdayHours ? `Sat ${c.saturdayHours}` : '',
|
if (c.weekdayHours) sections.push(` Mon–Fri: ${c.weekdayHours}`);
|
||||||
c.sundayHours ? `Sun ${c.sundayHours}` : 'Sun closed',
|
if (c.saturdayHours) sections.push(` Saturday: ${c.saturdayHours}`);
|
||||||
].filter(Boolean).join(', ');
|
sections.push(` Sunday: ${c.sundayHours ?? 'Closed'}`);
|
||||||
sections.push(`- ${c.clinicName ?? c.name}: ${addr}. ${hours}.`);
|
if (c.walkInAllowed) sections.push(` Walk-ins: Accepted`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rulesClinic = clinics[0];
|
const rulesClinic = clinics[0];
|
||||||
@@ -120,6 +562,39 @@ export class AiChatController {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Failed to fetch clinics: ${err}`);
|
this.logger.warn(`Failed to fetch clinics: ${err}`);
|
||||||
|
sections.push('## CLINICS\nFailed to load clinic data.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add doctors to KB
|
||||||
|
try {
|
||||||
|
const docData = await this.platform.queryWithAuth<any>(
|
||||||
|
`{ doctors(first: 20) { edges { node {
|
||||||
|
id fullName { firstName lastName } department specialty
|
||||||
|
consultationFeeNew { amountMicros currencyCode }
|
||||||
|
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||||
|
} } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
);
|
||||||
|
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}` : '';
|
||||||
|
// 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 (clinics) sections.push(` Clinics: ${clinics}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to fetch doctors for KB: ${err}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -155,6 +630,7 @@ export class AiChatController {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Failed to fetch health packages: ${err}`);
|
this.logger.warn(`Failed to fetch health packages: ${err}`);
|
||||||
|
sections.push('\n## Health Packages\nFailed to load package data.');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -175,6 +651,7 @@ export class AiChatController {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Failed to fetch insurance partners: ${err}`);
|
this.logger.warn(`Failed to fetch insurance partners: ${err}`);
|
||||||
|
sections.push('\n## Insurance Partners\nFailed to load insurance data.');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.knowledgeBase = sections.join('\n') || 'No hospital information available yet.';
|
this.knowledgeBase = sections.join('\n') || 'No hospital information available yet.';
|
||||||
@@ -183,26 +660,76 @@ export class AiChatController {
|
|||||||
return this.knowledgeBase;
|
return this.knowledgeBase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildSupervisorSystemPrompt(): string {
|
||||||
|
return this.aiConfig.renderPrompt('supervisorChat', {
|
||||||
|
hospitalName: this.getHospitalName(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
const configJson = JSON.stringify(currentConfig, null, 2);
|
||||||
|
return `You are an AI assistant helping a hospital supervisor configure the Rules Engine for their call center worklist.
|
||||||
|
|
||||||
|
## YOUR ROLE
|
||||||
|
You help the supervisor understand and optimize priority scoring rules. You explain concepts clearly and make specific recommendations based on their current configuration.
|
||||||
|
|
||||||
|
## SCORING FORMULA
|
||||||
|
finalScore = baseWeight × slaMultiplier × campaignMultiplier
|
||||||
|
|
||||||
|
- **Base Weight** (0-10): Each task type (missed calls, follow-ups, campaign leads, 2nd/3rd attempts) has a configurable weight. Higher weight = higher priority in the worklist.
|
||||||
|
- **SLA Multiplier**: Time-based urgency curve. Formula: elapsed^1.6 (accelerates as SLA deadline approaches). Past SLA breach: 1.0 + (excess × 0.5). This means items get increasingly urgent as they approach their SLA deadline.
|
||||||
|
- **Campaign Multiplier**: (campaignWeight/10) × (sourceWeight/10). IVF(9) × WhatsApp(9) = 0.81. Health(7) × Instagram(5) = 0.35.
|
||||||
|
- **SLA Thresholds**: Each task type has an SLA in minutes. Missed calls default to 12h (720min), follow-ups to 1d (1440min), campaign leads to 2d (2880min).
|
||||||
|
|
||||||
|
## SLA STATUS COLORS
|
||||||
|
- Green (low): < 50% SLA elapsed
|
||||||
|
- Amber (medium): 50-80% SLA elapsed
|
||||||
|
- Red (high): 80-100% SLA elapsed
|
||||||
|
- Dark red pulsing (critical): > 100% SLA elapsed (breached)
|
||||||
|
|
||||||
|
## PRIORITY RULES vs AUTOMATION RULES
|
||||||
|
- **Priority Rules** (what the supervisor is configuring now): Determine worklist order. Computed in real-time at request time. No permanent data changes.
|
||||||
|
- **Automation Rules** (coming soon): Trigger durable actions — assign leads to agents, escalate SLA breaches to supervisors, update lead status automatically. These write back to entity fields and need a draft/publish workflow.
|
||||||
|
|
||||||
|
## BEST PRACTICES FOR HOSPITAL CALL CENTERS
|
||||||
|
- Missed calls should have the highest weight (8-10) — these are patients who tried to reach you
|
||||||
|
- Follow-ups should be high (7-9) — you committed to calling them back
|
||||||
|
- Campaign leads vary by campaign value (5-8)
|
||||||
|
- SLA for missed calls: 4-12 hours (shorter = more responsive)
|
||||||
|
- SLA for follow-ups: 12-24 hours
|
||||||
|
- High-value campaigns (IVF, cancer screening): weight 8-9
|
||||||
|
- General campaigns (health checkup): weight 5-7
|
||||||
|
- WhatsApp/Phone leads convert better than social media → weight them higher
|
||||||
|
|
||||||
|
## CURRENT CONFIGURATION
|
||||||
|
${configJson}
|
||||||
|
|
||||||
|
## RULES
|
||||||
|
1. Be concise — under 100 words unless asked for detail
|
||||||
|
2. When recommending changes, be specific: "Set missed call weight to 9, SLA to 8 hours"
|
||||||
|
3. Explain WHY a change matters: "This ensures IVF patients get called within 4 hours"
|
||||||
|
4. Reference the scoring formula when explaining scores
|
||||||
|
5. If asked about automation rules, explain the concept and say it's coming soon`;
|
||||||
|
}
|
||||||
|
|
||||||
private buildSystemPrompt(kb: string): string {
|
private buildSystemPrompt(kb: string): string {
|
||||||
return `You are an AI assistant for call center agents at a hospital.
|
return this.aiConfig.renderPrompt('ccAgentHelper', {
|
||||||
You help agents answer questions about patients, doctors, appointments, and hospital services during live calls.
|
hospitalName: this.getHospitalName(),
|
||||||
|
knowledgeBase: kb,
|
||||||
RULES:
|
});
|
||||||
1. For patient-specific questions, you MUST use lookup tools. NEVER guess patient data.
|
|
||||||
2. For doctor schedule/fees, use the lookup_doctor tool to get real data from the system.
|
|
||||||
3. For hospital info (clinics, packages, insurance), use the knowledge base below.
|
|
||||||
4. If a tool returns no data, 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. NEVER share sensitive hospital data (revenue, salaries, internal policies).
|
|
||||||
8. Format with bullet points for easy scanning.
|
|
||||||
|
|
||||||
${kb}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async chatWithTools(userMessage: string, auth: string) {
|
private async chatWithTools(userMessage: string, auth: string) {
|
||||||
const kb = await this.buildKnowledgeBase(auth);
|
const kb = await this.buildKnowledgeBase(auth);
|
||||||
|
this.logger.log(`KB content preview: ${kb.substring(0, 300)}...`);
|
||||||
const systemPrompt = this.buildSystemPrompt(kb);
|
const systemPrompt = this.buildSystemPrompt(kb);
|
||||||
|
this.logger.log(`System prompt length: ${systemPrompt.length} chars, user message: "${userMessage.substring(0, 100)}"`);
|
||||||
const platformService = this.platform;
|
const platformService = this.platform;
|
||||||
|
|
||||||
const { text, steps } = await generateText({
|
const { text, steps } = await generateText({
|
||||||
@@ -309,16 +836,15 @@ ${kb}`;
|
|||||||
`{ doctors(first: 10) { edges { node {
|
`{ doctors(first: 10) { edges { node {
|
||||||
id name fullName { firstName lastName }
|
id name fullName { firstName lastName }
|
||||||
department specialty qualifications yearsOfExperience
|
department specialty qualifications yearsOfExperience
|
||||||
visitingHours
|
|
||||||
consultationFeeNew { amountMicros currencyCode }
|
consultationFeeNew { amountMicros currencyCode }
|
||||||
consultationFeeFollowUp { amountMicros currencyCode }
|
consultationFeeFollowUp { amountMicros currencyCode }
|
||||||
active registrationNumber
|
active registrationNumber
|
||||||
clinic { id name clinicName }
|
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, auth,
|
undefined, auth,
|
||||||
);
|
);
|
||||||
|
|
||||||
const doctors = data.doctors.edges.map((e: any) => e.node);
|
const doctors = normalizeDoctors(data.doctors.edges.map((e: any) => e.node));
|
||||||
const search = doctorName.toLowerCase();
|
const search = doctorName.toLowerCase();
|
||||||
const matched = doctors.filter((d: any) => {
|
const matched = doctors.filter((d: any) => {
|
||||||
const full = `${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''} ${d.name ?? ''}`.toLowerCase();
|
const full = `${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''} ${d.name ?? ''}`.toLowerCase();
|
||||||
@@ -331,7 +857,13 @@ ${kb}`;
|
|||||||
found: true,
|
found: true,
|
||||||
doctors: matched.map((d: any) => ({
|
doctors: matched.map((d: any) => ({
|
||||||
...d,
|
...d,
|
||||||
clinicName: d.clinic?.clinicName ?? d.clinic?.name ?? 'N/A',
|
// Multi-clinic doctors show as
|
||||||
|
// "Koramangala / Indiranagar" so the
|
||||||
|
// model has the full picture without
|
||||||
|
// a follow-up tool call.
|
||||||
|
clinicName: d.clinics.length > 0
|
||||||
|
? d.clinics.map((c: { clinicName: string }) => c.clinicName).join(' / ')
|
||||||
|
: 'N/A',
|
||||||
feeNewFormatted: d.consultationFeeNew ? `₹${d.consultationFeeNew.amountMicros / 1_000_000}` : 'N/A',
|
feeNewFormatted: d.consultationFeeNew ? `₹${d.consultationFeeNew.amountMicros / 1_000_000}` : 'N/A',
|
||||||
feeFollowUpFormatted: d.consultationFeeFollowUp ? `₹${d.consultationFeeFollowUp.amountMicros / 1_000_000}` : 'N/A',
|
feeFollowUpFormatted: d.consultationFeeFollowUp ? `₹${d.consultationFeeFollowUp.amountMicros / 1_000_000}` : 'N/A',
|
||||||
})),
|
})),
|
||||||
@@ -355,13 +887,13 @@ ${kb}`;
|
|||||||
try {
|
try {
|
||||||
const doctors = await this.platform.queryWithAuth<any>(
|
const doctors = await this.platform.queryWithAuth<any>(
|
||||||
`{ doctors(first: 10) { edges { node {
|
`{ doctors(first: 10) { edges { node {
|
||||||
name fullName { firstName lastName } department specialty visitingHours
|
id name fullName { firstName lastName } department specialty
|
||||||
consultationFeeNew { amountMicros currencyCode }
|
consultationFeeNew { amountMicros currencyCode }
|
||||||
clinic { name clinicName }
|
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, auth,
|
undefined, auth,
|
||||||
);
|
);
|
||||||
const docs = doctors.doctors.edges.map((e: any) => e.node);
|
const docs = normalizeDoctors(doctors.doctors.edges.map((e: any) => e.node));
|
||||||
const l = msg.toLowerCase();
|
const l = msg.toLowerCase();
|
||||||
|
|
||||||
const matchedDoc = docs.find((d: any) => {
|
const matchedDoc = docs.find((d: any) => {
|
||||||
@@ -371,7 +903,7 @@ ${kb}`;
|
|||||||
if (matchedDoc) {
|
if (matchedDoc) {
|
||||||
const fee = matchedDoc.consultationFeeNew ? `₹${matchedDoc.consultationFeeNew.amountMicros / 1_000_000}` : '';
|
const fee = matchedDoc.consultationFeeNew ? `₹${matchedDoc.consultationFeeNew.amountMicros / 1_000_000}` : '';
|
||||||
const clinic = matchedDoc.clinic?.clinicName ?? '';
|
const clinic = matchedDoc.clinic?.clinicName ?? '';
|
||||||
return `Dr. ${matchedDoc.fullName?.lastName ?? matchedDoc.name} (${matchedDoc.department ?? matchedDoc.specialty}): ${matchedDoc.visitingHours ?? 'hours not set'}${clinic ? ` at ${clinic}` : ''}${fee ? `. Fee: ${fee}` : ''}.`;
|
return `Dr. ${matchedDoc.fullName?.lastName ?? matchedDoc.name} (${matchedDoc.department ?? matchedDoc.specialty}): ${matchedDoc.visitingHours || 'hours not set'}${clinic ? ` at ${clinic}` : ''}${fee ? `. Fee: ${fee}` : ''}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (l.includes('doctor') || l.includes('available')) {
|
if (l.includes('doctor') || l.includes('available')) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { generateObject } from 'ai';
|
|||||||
import type { LanguageModel } from 'ai';
|
import type { LanguageModel } from 'ai';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { createAiModel } from './ai-provider';
|
import { createAiModel } from './ai-provider';
|
||||||
|
import { AiConfigService } from '../config/ai-config.service';
|
||||||
|
|
||||||
type LeadContext = {
|
type LeadContext = {
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
@@ -32,8 +33,17 @@ export class AiEnrichmentService {
|
|||||||
private readonly logger = new Logger(AiEnrichmentService.name);
|
private readonly logger = new Logger(AiEnrichmentService.name);
|
||||||
private readonly aiModel: LanguageModel | null;
|
private readonly aiModel: LanguageModel | null;
|
||||||
|
|
||||||
constructor(private config: ConfigService) {
|
constructor(
|
||||||
this.aiModel = createAiModel(config);
|
private config: ConfigService,
|
||||||
|
private aiConfig: AiConfigService,
|
||||||
|
) {
|
||||||
|
const cfg = aiConfig.getConfig();
|
||||||
|
this.aiModel = createAiModel({
|
||||||
|
provider: cfg.provider,
|
||||||
|
model: cfg.model,
|
||||||
|
anthropicApiKey: config.get<string>('ai.anthropicApiKey'),
|
||||||
|
openaiApiKey: config.get<string>('ai.openaiApiKey'),
|
||||||
|
});
|
||||||
if (!this.aiModel) {
|
if (!this.aiModel) {
|
||||||
this.logger.warn('AI not configured — enrichment uses fallback');
|
this.logger.warn('AI not configured — enrichment uses fallback');
|
||||||
}
|
}
|
||||||
@@ -56,19 +66,15 @@ export class AiEnrichmentService {
|
|||||||
const { object } = await generateObject({
|
const { object } = await generateObject({
|
||||||
model: this.aiModel!,
|
model: this.aiModel!,
|
||||||
schema: enrichmentSchema,
|
schema: enrichmentSchema,
|
||||||
prompt: `You are an AI assistant for a hospital call center.
|
prompt: this.aiConfig.renderPrompt('leadEnrichment', {
|
||||||
An inbound call is coming in from a lead. Summarize their history and suggest what the call center agent should do.
|
leadName: `${lead.firstName ?? 'Unknown'} ${lead.lastName ?? ''}`.trim(),
|
||||||
|
leadSource: lead.leadSource ?? 'Unknown',
|
||||||
Lead details:
|
interestedService: lead.interestedService ?? 'Unknown',
|
||||||
- Name: ${lead.firstName ?? 'Unknown'} ${lead.lastName ?? ''}
|
leadStatus: lead.leadStatus ?? 'Unknown',
|
||||||
- Source: ${lead.leadSource ?? 'Unknown'}
|
daysSince,
|
||||||
- Interested in: ${lead.interestedService ?? 'Unknown'}
|
contactAttempts: lead.contactAttempts ?? 0,
|
||||||
- Current status: ${lead.leadStatus ?? 'Unknown'}
|
activities: activitiesText,
|
||||||
- Lead age: ${daysSince} days
|
}),
|
||||||
- Contact attempts: ${lead.contactAttempts ?? 0}
|
|
||||||
|
|
||||||
Recent activity:
|
|
||||||
${activitiesText}`,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`AI enrichment generated for lead ${lead.firstName} ${lead.lastName}`);
|
this.logger.log(`AI enrichment generated for lead ${lead.firstName} ${lead.lastName}`);
|
||||||
|
|||||||
@@ -1,26 +1,30 @@
|
|||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { anthropic } from '@ai-sdk/anthropic';
|
import { anthropic } from '@ai-sdk/anthropic';
|
||||||
import { openai } from '@ai-sdk/openai';
|
import { openai } from '@ai-sdk/openai';
|
||||||
import type { LanguageModel } from 'ai';
|
import type { LanguageModel } from 'ai';
|
||||||
|
|
||||||
export function createAiModel(config: ConfigService): LanguageModel | null {
|
// Pure factory — no DI. Caller passes provider/model (admin-editable, from
|
||||||
const provider = config.get<string>('ai.provider') ?? 'openai';
|
// AiConfigService) and the API key (env-driven, ops-owned). Decoupling means
|
||||||
const model = config.get<string>('ai.model') ?? 'gpt-4o-mini';
|
// the model can be re-built per request without re-instantiating the caller
|
||||||
|
// service, so admin updates to provider/model take effect immediately.
|
||||||
|
|
||||||
if (provider === 'anthropic') {
|
export type AiProviderOpts = {
|
||||||
const apiKey = config.get<string>('ai.anthropicApiKey');
|
provider: string;
|
||||||
if (!apiKey) return null;
|
model: string;
|
||||||
return anthropic(model);
|
anthropicApiKey?: string;
|
||||||
|
openaiApiKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createAiModel(opts: AiProviderOpts): LanguageModel | null {
|
||||||
|
if (opts.provider === 'anthropic') {
|
||||||
|
if (!opts.anthropicApiKey) return null;
|
||||||
|
return anthropic(opts.model);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default to openai
|
// Default to openai
|
||||||
const apiKey = config.get<string>('ai.openaiApiKey');
|
if (!opts.openaiApiKey) return null;
|
||||||
if (!apiKey) return null;
|
return openai(opts.model);
|
||||||
return openai(model);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAiConfigured(config: ConfigService): boolean {
|
export function isAiConfigured(opts: AiProviderOpts): boolean {
|
||||||
const provider = config.get<string>('ai.provider') ?? 'openai';
|
if (opts.provider === 'anthropic') return !!opts.anthropicApiKey;
|
||||||
if (provider === 'anthropic') return !!config.get<string>('ai.anthropicApiKey');
|
return !!opts.openaiApiKey;
|
||||||
return !!config.get<string>('ai.openaiApiKey');
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ import { HealthModule } from './health/health.module';
|
|||||||
import { WorklistModule } from './worklist/worklist.module';
|
import { WorklistModule } from './worklist/worklist.module';
|
||||||
import { CallAssistModule } from './call-assist/call-assist.module';
|
import { CallAssistModule } from './call-assist/call-assist.module';
|
||||||
import { SearchModule } from './search/search.module';
|
import { SearchModule } from './search/search.module';
|
||||||
|
import { SupervisorModule } from './supervisor/supervisor.module';
|
||||||
|
import { MaintModule } from './maint/maint.module';
|
||||||
|
import { RecordingsModule } from './recordings/recordings.module';
|
||||||
|
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';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -30,6 +39,15 @@ import { SearchModule } from './search/search.module';
|
|||||||
WorklistModule,
|
WorklistModule,
|
||||||
CallAssistModule,
|
CallAssistModule,
|
||||||
SearchModule,
|
SearchModule,
|
||||||
|
SupervisorModule,
|
||||||
|
MaintModule,
|
||||||
|
RecordingsModule,
|
||||||
|
EventsModule,
|
||||||
|
CallerResolutionModule,
|
||||||
|
RulesEngineModule,
|
||||||
|
ConfigThemeModule,
|
||||||
|
WidgetModule,
|
||||||
|
TeamModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
82
src/auth/agent-config.service.ts
Normal file
82
src/auth/agent-config.service.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AgentConfigService {
|
||||||
|
private readonly logger = new Logger(AgentConfigService.name);
|
||||||
|
private readonly cache = new Map<string, AgentConfig>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private platform: PlatformGraphqlService,
|
||||||
|
private telephony: TelephonyConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByMemberId(memberId: string): Promise<AgentConfig | null> {
|
||||||
|
const cached = this.cache.get(memberId);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import { Controller, Post, Body, Logger, HttpException } from '@nestjs/common';
|
import { Controller, Post, Body, Headers, Req, Logger, HttpException } from '@nestjs/common';
|
||||||
|
import type { Request } from 'express';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
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')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
@@ -13,6 +17,9 @@ export class AuthController {
|
|||||||
constructor(
|
constructor(
|
||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
private ozonetelAgent: OzonetelAgentService,
|
private ozonetelAgent: OzonetelAgentService,
|
||||||
|
private sessionService: SessionService,
|
||||||
|
private agentConfigService: AgentConfigService,
|
||||||
|
private telephony: TelephonyConfigService,
|
||||||
) {
|
) {
|
||||||
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
|
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
|
||||||
this.workspaceSubdomain = process.env.PLATFORM_WORKSPACE_SUBDOMAIN ?? 'fortytwo-dev';
|
this.workspaceSubdomain = process.env.PLATFORM_WORKSPACE_SUBDOMAIN ?? 'fortytwo-dev';
|
||||||
@@ -20,7 +27,7 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
async login(@Body() body: { email: string; password: string }) {
|
async login(@Body() body: { email: string; password: string }, @Req() req: Request) {
|
||||||
this.logger.log(`Login attempt for ${body.email}`);
|
this.logger.log(`Login attempt for ${body.email}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -111,20 +118,60 @@ export class AuthController {
|
|||||||
|
|
||||||
this.logger.log(`User ${body.email} logged in with role: ${appRole} (platform roles: ${roleLabels.join(', ')})`);
|
this.logger.log(`User ${body.email} logged in with role: ${appRole} (platform roles: ${roleLabels.join(', ')})`);
|
||||||
|
|
||||||
// Auto-login Ozonetel agent for CC agents (fire and forget)
|
// Check if user has an Agent entity with SIP config — applies to ALL roles
|
||||||
if (appRole === 'cc-agent') {
|
let agentConfigResponse: any = undefined;
|
||||||
const ozAgentId = process.env.OZONETEL_AGENT_ID ?? 'agent3';
|
const memberId = workspaceMember?.id;
|
||||||
const ozAgentPassword = process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$';
|
|
||||||
const ozSipId = process.env.OZONETEL_SIP_ID ?? '521814';
|
|
||||||
|
|
||||||
this.ozonetelAgent.loginAgent({
|
if (memberId) {
|
||||||
agentId: ozAgentId,
|
const agentConfig = await this.agentConfigService.getByMemberId(memberId);
|
||||||
password: ozAgentPassword,
|
|
||||||
phoneNumber: ozSipId,
|
if (agentConfig) {
|
||||||
mode: 'blended',
|
// Agent entity found — set up SIP + Ozonetel
|
||||||
}).catch(err => {
|
const clientIp = (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ?? req.ip ?? 'unknown';
|
||||||
this.logger.warn(`Ozonetel agent login failed (non-blocking): ${err.message}`);
|
const existingSession = await this.sessionService.getSession(agentConfig.ozonetelAgentId);
|
||||||
});
|
if (existingSession) {
|
||||||
|
this.logger.warn(`Duplicate login blocked for ${body.email} — session held by IP ${existingSession.ip} since ${existingSession.lockedAt}`);
|
||||||
|
throw new HttpException(`You are already logged in from another device (${existingSession.ip}). Please log out there first.`, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.sessionService.lockSession(agentConfig.ozonetelAgentId, memberId, clientIp);
|
||||||
|
|
||||||
|
this.ozonetelAgent.refreshToken().catch(err => {
|
||||||
|
this.logger.warn(`Ozonetel token refresh on login failed: ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ozAgentPassword = this.telephony.getConfig().ozonetel.agentPassword || 'Test123$';
|
||||||
|
this.ozonetelAgent.loginAgent({
|
||||||
|
agentId: agentConfig.ozonetelAgentId,
|
||||||
|
password: ozAgentPassword,
|
||||||
|
phoneNumber: agentConfig.sipExtension,
|
||||||
|
mode: 'blended',
|
||||||
|
}).catch(err => {
|
||||||
|
this.logger.warn(`Ozonetel agent login failed (non-blocking): ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
agentConfigResponse = {
|
||||||
|
ozonetelAgentId: agentConfig.ozonetelAgentId,
|
||||||
|
sipExtension: agentConfig.sipExtension,
|
||||||
|
sipPassword: agentConfig.sipPassword,
|
||||||
|
sipUri: agentConfig.sipUri,
|
||||||
|
sipWsServer: agentConfig.sipWsServer,
|
||||||
|
campaignName: agentConfig.campaignName,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logger.log(`Agent ${body.email} → Ozonetel ${agentConfig.ozonetelAgentId} / SIP ${agentConfig.sipExtension}`);
|
||||||
|
} else if (appRole === 'cc-agent') {
|
||||||
|
// CC agent role but no Agent entity — block login
|
||||||
|
throw new HttpException('Agent account not configured. Contact administrator.', 403);
|
||||||
|
} else {
|
||||||
|
this.logger.log(`User ${body.email} has no Agent entity — SIP disabled`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache agent name for worklist resolution (avoids re-querying currentUser with user JWT)
|
||||||
|
const agentFullName = `${workspaceMember?.name?.firstName ?? ''} ${workspaceMember?.name?.lastName ?? ''}`.trim();
|
||||||
|
if (agentFullName) {
|
||||||
|
await this.sessionService.setCache(`agent:name:${accessToken.slice(-16)}`, agentFullName, 86400);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -139,6 +186,7 @@ export class AuthController {
|
|||||||
role: appRole,
|
role: appRole,
|
||||||
platformRoles: roleLabels,
|
platformRoles: roleLabels,
|
||||||
},
|
},
|
||||||
|
...(agentConfigResponse ? { agentConfig: agentConfigResponse } : {}),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
@@ -186,4 +234,58 @@ export class AuthController {
|
|||||||
throw new HttpException('Token refresh failed', 401);
|
throw new HttpException('Token refresh failed', 401);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('logout')
|
||||||
|
async logout(@Headers('authorization') auth: string) {
|
||||||
|
if (!auth) return { status: 'ok' };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profileRes = await axios.post(this.graphqlUrl, {
|
||||||
|
query: '{ currentUser { workspaceMember { id } } }',
|
||||||
|
}, { headers: { 'Content-Type': 'application/json', Authorization: auth } });
|
||||||
|
|
||||||
|
const memberId = profileRes.data?.data?.currentUser?.workspaceMember?.id;
|
||||||
|
if (!memberId) return { status: 'ok' };
|
||||||
|
|
||||||
|
const agentConfig = this.agentConfigService.getFromCache(memberId);
|
||||||
|
if (agentConfig) {
|
||||||
|
await this.sessionService.unlockSession(agentConfig.ozonetelAgentId);
|
||||||
|
this.logger.log(`Session unlocked for ${agentConfig.ozonetelAgentId}`);
|
||||||
|
|
||||||
|
this.ozonetelAgent.logoutAgent({
|
||||||
|
agentId: agentConfig.ozonetelAgentId,
|
||||||
|
password: this.telephony.getConfig().ozonetel.agentPassword || 'Test123$',
|
||||||
|
}).catch(err => this.logger.warn(`Ozonetel logout failed: ${err.message}`));
|
||||||
|
|
||||||
|
this.agentConfigService.clearCache(memberId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 'ok' };
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Logout cleanup failed: ${err}`);
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('heartbeat')
|
||||||
|
async heartbeat(@Headers('authorization') auth: string) {
|
||||||
|
if (!auth) return { status: 'ok' };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profileRes = await axios.post(this.graphqlUrl, {
|
||||||
|
query: '{ currentUser { workspaceMember { id } } }',
|
||||||
|
}, { headers: { 'Content-Type': 'application/json', Authorization: auth } });
|
||||||
|
|
||||||
|
const memberId = profileRes.data?.data?.currentUser?.workspaceMember?.id;
|
||||||
|
const agentConfig = memberId ? this.agentConfigService.getFromCache(memberId) : null;
|
||||||
|
|
||||||
|
if (agentConfig) {
|
||||||
|
await this.sessionService.refreshSession(agentConfig.ozonetelAgentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 'ok' };
|
||||||
|
} catch {
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
import { SessionService } from './session.service';
|
||||||
|
import { AgentConfigService } from './agent-config.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [OzonetelAgentModule],
|
imports: [OzonetelAgentModule, PlatformModule],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
|
providers: [SessionService, AgentConfigService],
|
||||||
|
exports: [SessionService, AgentConfigService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
85
src/auth/session.service.ts
Normal file
85
src/auth/session.service.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
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 {
|
||||||
|
private readonly logger = new Logger(SessionService.name);
|
||||||
|
private readonly redis: Redis;
|
||||||
|
|
||||||
|
// 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, { lazyConnect: false });
|
||||||
|
this.redis.on('connect', () => this.logger.log('Redis connected'));
|
||||||
|
this.redis.on('error', (err) => this.logger.error(`Redis error: ${err.message}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
private key(agentId: string): string {
|
||||||
|
return `agent:session:${agentId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async lockSession(agentId: string, memberId: string, ip?: string): Promise<void> {
|
||||||
|
const value = JSON.stringify({ memberId, ip: ip ?? 'unknown', lockedAt: new Date().toISOString() });
|
||||||
|
await this.redis.set(this.key(agentId), value, 'EX', SESSION_TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSession(agentId: string): Promise<{ memberId: string; ip: string; lockedAt: string } | null> {
|
||||||
|
const raw = await this.redis.get(this.key(agentId));
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
// Legacy format — just memberId string
|
||||||
|
return { memberId: raw, ip: 'unknown', lockedAt: 'unknown' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async isSessionLocked(agentId: string): Promise<string | null> {
|
||||||
|
const session = await this.getSession(agentId);
|
||||||
|
return session ? session.memberId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshSession(agentId: string): Promise<void> {
|
||||||
|
await this.redis.expire(this.key(agentId), SESSION_TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
async unlockSession(agentId: string): Promise<void> {
|
||||||
|
await this.redis.del(this.key(agentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic cache operations for any module
|
||||||
|
async getCache(key: string): Promise<string | null> {
|
||||||
|
return this.redis.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setCache(key: string, value: string, ttlSeconds: number): Promise<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
async scanKeys(pattern: string): Promise<string[]> {
|
||||||
|
const keys: string[] = [];
|
||||||
|
let cursor = '0';
|
||||||
|
do {
|
||||||
|
const [next, batch] = await this.redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
|
||||||
|
cursor = next;
|
||||||
|
keys.push(...batch);
|
||||||
|
} while (cursor !== '0');
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import { generateText } from 'ai';
|
|||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
import { createAiModel } from '../ai/ai-provider';
|
import { createAiModel } from '../ai/ai-provider';
|
||||||
import type { LanguageModel } from 'ai';
|
import type { LanguageModel } from 'ai';
|
||||||
|
import { AiConfigService } from '../config/ai-config.service';
|
||||||
|
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../shared/doctor-utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CallAssistService {
|
export class CallAssistService {
|
||||||
@@ -14,8 +16,15 @@ export class CallAssistService {
|
|||||||
constructor(
|
constructor(
|
||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
private platform: PlatformGraphqlService,
|
private platform: PlatformGraphqlService,
|
||||||
|
private aiConfig: AiConfigService,
|
||||||
) {
|
) {
|
||||||
this.aiModel = createAiModel(config);
|
const cfg = aiConfig.getConfig();
|
||||||
|
this.aiModel = createAiModel({
|
||||||
|
provider: cfg.provider,
|
||||||
|
model: cfg.model,
|
||||||
|
anthropicApiKey: config.get<string>('ai.anthropicApiKey'),
|
||||||
|
openaiApiKey: config.get<string>('ai.openaiApiKey'),
|
||||||
|
});
|
||||||
this.platformApiKey = config.get<string>('platform.apiKey') ?? '';
|
this.platformApiKey = config.get<string>('platform.apiKey') ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +61,7 @@ export class CallAssistService {
|
|||||||
|
|
||||||
const apptResult = await this.platform.queryWithAuth<any>(
|
const apptResult = await this.platform.queryWithAuth<any>(
|
||||||
`{ appointments(first: 10, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
`{ appointments(first: 10, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||||
id scheduledAt appointmentStatus doctorName department reasonForVisit patientId
|
id scheduledAt status doctorName department reasonForVisit patientId
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, authHeader,
|
undefined, authHeader,
|
||||||
);
|
);
|
||||||
@@ -63,7 +72,7 @@ export class CallAssistService {
|
|||||||
parts.push('\nPAST APPOINTMENTS:');
|
parts.push('\nPAST APPOINTMENTS:');
|
||||||
for (const a of appts) {
|
for (const a of appts) {
|
||||||
const date = a.scheduledAt ? new Date(a.scheduledAt).toLocaleDateString('en-IN') : '?';
|
const date = a.scheduledAt ? new Date(a.scheduledAt).toLocaleDateString('en-IN') : '?';
|
||||||
parts.push(`- ${date}: ${a.doctorName ?? '?'} (${a.department ?? '?'}) — ${a.appointmentStatus}`);
|
parts.push(`- ${date}: ${a.doctorName ?? '?'} (${a.department ?? '?'}) — ${a.status}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (callerPhone) {
|
} else if (callerPhone) {
|
||||||
@@ -73,16 +82,24 @@ export class CallAssistService {
|
|||||||
|
|
||||||
const docResult = await this.platform.queryWithAuth<any>(
|
const docResult = await this.platform.queryWithAuth<any>(
|
||||||
`{ doctors(first: 20) { edges { node {
|
`{ doctors(first: 20) { edges { node {
|
||||||
fullName { firstName lastName } department specialty clinic { clinicName }
|
id fullName { firstName lastName } department specialty
|
||||||
|
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, authHeader,
|
undefined, authHeader,
|
||||||
);
|
);
|
||||||
const docs = docResult.doctors.edges.map((e: any) => e.node);
|
const docs = normalizeDoctors(docResult.doctors.edges.map((e: any) => e.node));
|
||||||
if (docs.length > 0) {
|
if (docs.length > 0) {
|
||||||
parts.push('\nAVAILABLE DOCTORS:');
|
parts.push('\nAVAILABLE DOCTORS:');
|
||||||
for (const d of docs) {
|
for (const d of docs) {
|
||||||
const name = d.fullName ? `Dr. ${d.fullName.firstName} ${d.fullName.lastName}`.trim() : 'Unknown';
|
const name = d.fullName ? `Dr. ${d.fullName.firstName} ${d.fullName.lastName}`.trim() : 'Unknown';
|
||||||
parts.push(`- ${name} — ${d.department ?? '?'} — ${d.clinic?.clinicName ?? '?'}`);
|
// Show all clinics the doctor visits, joined with
|
||||||
|
// " / " — call assist context is read by the AI
|
||||||
|
// whisperer so multi-clinic doctors don't get
|
||||||
|
// truncated to their first location.
|
||||||
|
const clinicLabel = d.clinics.length > 0
|
||||||
|
? d.clinics.map((c) => c.clinicName).join(' / ')
|
||||||
|
: '?';
|
||||||
|
parts.push(`- ${name} — ${d.department ?? '?'} — ${clinicLabel}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,18 +116,10 @@ export class CallAssistService {
|
|||||||
try {
|
try {
|
||||||
const { text } = await generateText({
|
const { text } = await generateText({
|
||||||
model: this.aiModel,
|
model: this.aiModel,
|
||||||
system: `You are a real-time call assistant for Global Hospital Bangalore.
|
system: this.aiConfig.renderPrompt('callAssist', {
|
||||||
You listen to the customer's words and provide brief, actionable suggestions for the CC agent.
|
hospitalName: process.env.HOSPITAL_NAME ?? 'the hospital',
|
||||||
|
context,
|
||||||
${context}
|
}),
|
||||||
|
|
||||||
RULES:
|
|
||||||
- Keep suggestions under 2 sentences
|
|
||||||
- Focus on actionable next steps the agent should take NOW
|
|
||||||
- If customer mentions a doctor or department, suggest available slots
|
|
||||||
- If customer wants to cancel or reschedule, note relevant appointment details
|
|
||||||
- If customer sounds upset, suggest empathetic response
|
|
||||||
- Do NOT repeat what the agent already knows`,
|
|
||||||
prompt: `Conversation transcript so far:\n${transcript}\n\nProvide a brief suggestion for the agent based on what was just said.`,
|
prompt: `Conversation transcript so far:\n${transcript}\n\nProvide a brief suggestion for the agent based on what was just said.`,
|
||||||
maxOutputTokens: 150,
|
maxOutputTokens: 150,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,6 +35,20 @@ export class CallEventsGateway {
|
|||||||
this.server.to(room).emit('call:incoming', event);
|
this.server.to(room).emit('call:incoming', event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Broadcast to supervisors when a new call record is created
|
||||||
|
broadcastCallCreated(callData: any) {
|
||||||
|
this.logger.log('Broadcasting call:created to supervisor room');
|
||||||
|
this.server.to('supervisor').emit('call:created', callData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supervisor registers to receive real-time updates
|
||||||
|
@SubscribeMessage('supervisor:register')
|
||||||
|
handleSupervisorRegister(@ConnectedSocket() client: Socket) {
|
||||||
|
client.join('supervisor');
|
||||||
|
this.logger.log(`Supervisor registered (socket: ${client.id})`);
|
||||||
|
client.emit('supervisor:registered', { room: 'supervisor' });
|
||||||
|
}
|
||||||
|
|
||||||
// Agent registers when they open the Call Desk page
|
// Agent registers when they open the Call Desk page
|
||||||
@SubscribeMessage('agent:register')
|
@SubscribeMessage('agent:register')
|
||||||
handleAgentRegister(
|
handleAgentRegister(
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PlatformModule } from '../platform/platform.module';
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
import { AiModule } from '../ai/ai.module';
|
import { AiModule } from '../ai/ai.module';
|
||||||
|
import { CallerResolutionModule } from '../caller/caller-resolution.module';
|
||||||
import { CallEventsService } from './call-events.service';
|
import { CallEventsService } from './call-events.service';
|
||||||
import { CallEventsGateway } from './call-events.gateway';
|
import { CallEventsGateway } from './call-events.gateway';
|
||||||
import { CallLookupController } from './call-lookup.controller';
|
import { CallLookupController } from './call-lookup.controller';
|
||||||
|
import { LeadEnrichController } from './lead-enrich.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule, AiModule],
|
// CallerResolutionModule is imported so LeadEnrichController can
|
||||||
controllers: [CallLookupController],
|
// inject CallerResolutionService to invalidate the Redis caller
|
||||||
|
// cache after a forced re-enrichment.
|
||||||
|
imports: [PlatformModule, AiModule, CallerResolutionModule],
|
||||||
|
controllers: [CallLookupController, LeadEnrichController],
|
||||||
providers: [CallEventsService, CallEventsGateway],
|
providers: [CallEventsService, CallEventsGateway],
|
||||||
exports: [CallEventsService, CallEventsGateway],
|
exports: [CallEventsService, CallEventsGateway],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -167,7 +167,24 @@ export class CallEventsService {
|
|||||||
`Processing disposition: ${payload.disposition} for call ${payload.callSid}`,
|
`Processing disposition: ${payload.disposition} for call ${payload.callSid}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 1. Create Call record in platform
|
// 1. Compute SLA % if lead is linked
|
||||||
|
let sla: number | undefined;
|
||||||
|
if (payload.leadId && payload.startedAt) {
|
||||||
|
try {
|
||||||
|
const lead = await this.platform.findLeadById(payload.leadId);
|
||||||
|
if (lead?.createdAt) {
|
||||||
|
const leadCreated = new Date(lead.createdAt).getTime();
|
||||||
|
const callStarted = new Date(payload.startedAt).getTime();
|
||||||
|
const elapsedMin = Math.max(0, (callStarted - leadCreated) / 60000);
|
||||||
|
const slaThresholdMin = 1440; // Default 24h; missed calls use 720 but this is a completed call
|
||||||
|
sla = Math.round((elapsedMin / slaThresholdMin) * 100);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// SLA computation is best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create Call record in platform
|
||||||
try {
|
try {
|
||||||
await this.platform.createCall({
|
await this.platform.createCall({
|
||||||
callDirection: 'INBOUND',
|
callDirection: 'INBOUND',
|
||||||
@@ -187,8 +204,11 @@ export class CallEventsService {
|
|||||||
disposition: payload.disposition,
|
disposition: payload.disposition,
|
||||||
callNotes: payload.notes || undefined,
|
callNotes: payload.notes || undefined,
|
||||||
leadId: payload.leadId || undefined,
|
leadId: payload.leadId || undefined,
|
||||||
|
sla,
|
||||||
});
|
});
|
||||||
this.logger.log(`Call record created for ${payload.callSid}`);
|
this.logger.log(`Call record created for ${payload.callSid} (SLA: ${sla ?? 'N/A'}%)`);
|
||||||
|
// Notify supervisors in real-time
|
||||||
|
this.gateway.broadcastCallCreated({ callSid: payload.callSid, agentName: payload.agentName, disposition: payload.disposition });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to create call record: ${error}`);
|
this.logger.error(`Failed to create call record: ${error}`);
|
||||||
}
|
}
|
||||||
|
|||||||
122
src/call-events/lead-enrich.controller.ts
Normal file
122
src/call-events/lead-enrich.controller.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Invalidate the caller cache so the next incoming call from
|
||||||
|
// this phone number does a fresh platform lookup (and picks
|
||||||
|
// up the corrected identity + new summary).
|
||||||
|
if (body?.phone) {
|
||||||
|
try {
|
||||||
|
await this.callerResolution.invalidate(body.phone);
|
||||||
|
this.logger.log(`[LEAD-ENRICH] Caller cache invalidated for ${body.phone}`);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[LEAD-ENRICH] Failed to invalidate caller cache: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[LEAD-ENRICH] Lead ${leadId} enriched successfully`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
leadId,
|
||||||
|
aiSummary: enrichment.aiSummary,
|
||||||
|
aiSuggestedAction: enrichment.aiSuggestedAction,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/caller/caller-resolution.controller.ts
Normal file
36
src/caller/caller-resolution.controller.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Controller, Post, Body, Headers, HttpException, HttpStatus, Logger } from '@nestjs/common';
|
||||||
|
import { CallerResolutionService } from './caller-resolution.service';
|
||||||
|
|
||||||
|
@Controller('api/caller')
|
||||||
|
export class CallerResolutionController {
|
||||||
|
private readonly logger = new Logger(CallerResolutionController.name);
|
||||||
|
|
||||||
|
constructor(private readonly resolution: CallerResolutionService) {}
|
||||||
|
|
||||||
|
@Post('resolve')
|
||||||
|
async resolve(
|
||||||
|
@Body('phone') phone: string,
|
||||||
|
@Headers('authorization') auth: string,
|
||||||
|
) {
|
||||||
|
if (!phone) {
|
||||||
|
throw new HttpException('phone is required', HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
if (!auth) {
|
||||||
|
throw new HttpException('Authorization header required', HttpStatus.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[RESOLVE] Resolving caller: ${phone}`);
|
||||||
|
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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/caller/caller-resolution.module.ts
Normal file
13
src/caller/caller-resolution.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 { CallerResolutionController } from './caller-resolution.controller';
|
||||||
|
import { CallerResolutionService } from './caller-resolution.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PlatformModule, AuthModule],
|
||||||
|
controllers: [CallerResolutionController],
|
||||||
|
providers: [CallerResolutionService],
|
||||||
|
exports: [CallerResolutionService],
|
||||||
|
})
|
||||||
|
export class CallerResolutionModule {}
|
||||||
216
src/caller/caller-resolution.service.ts
Normal file
216
src/caller/caller-resolution.service.ts
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
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;
|
||||||
|
patientId: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
phone: string;
|
||||||
|
isNew: boolean; // true if we just created the lead+patient pair
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CallerResolutionService {
|
||||||
|
private readonly logger = new Logger(CallerResolutionService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly platform: PlatformGraphqlService,
|
||||||
|
private readonly cache: SessionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// Resolve a caller by phone number. Always returns a paired lead + patient.
|
||||||
|
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);
|
||||||
|
|
||||||
|
let result: ResolvedCaller;
|
||||||
|
|
||||||
|
if (lead && patient) {
|
||||||
|
// Both exist — link them if not already linked
|
||||||
|
if (!lead.patientId) {
|
||||||
|
await this.linkLeadToPatient(lead.id, patient.id, auth);
|
||||||
|
this.logger.log(`[RESOLVE] Linked existing lead ${lead.id} → patient ${patient.id}`);
|
||||||
|
}
|
||||||
|
result = {
|
||||||
|
leadId: lead.id,
|
||||||
|
patientId: patient.id,
|
||||||
|
firstName: lead.firstName || patient.firstName,
|
||||||
|
lastName: lead.lastName || patient.lastName,
|
||||||
|
phone: normalized,
|
||||||
|
isNew: false,
|
||||||
|
};
|
||||||
|
} else if (lead && !patient) {
|
||||||
|
// Lead exists, no patient — create patient
|
||||||
|
const newPatient = await this.createPatient(lead.firstName, lead.lastName, normalized, auth);
|
||||||
|
await this.linkLeadToPatient(lead.id, newPatient.id, auth);
|
||||||
|
this.logger.log(`[RESOLVE] Created patient ${newPatient.id} for existing lead ${lead.id}`);
|
||||||
|
result = {
|
||||||
|
leadId: lead.id,
|
||||||
|
patientId: newPatient.id,
|
||||||
|
firstName: lead.firstName,
|
||||||
|
lastName: lead.lastName,
|
||||||
|
phone: normalized,
|
||||||
|
isNew: false,
|
||||||
|
};
|
||||||
|
} else if (!lead && patient) {
|
||||||
|
// 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}`);
|
||||||
|
result = {
|
||||||
|
leadId: newLead.id,
|
||||||
|
patientId: patient.id,
|
||||||
|
firstName: patient.firstName,
|
||||||
|
lastName: patient.lastName,
|
||||||
|
phone: normalized,
|
||||||
|
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}`);
|
||||||
|
result = {
|
||||||
|
leadId: newLead.id,
|
||||||
|
patientId: newPatient.id,
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
phone: normalized,
|
||||||
|
isNew: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: match.node.id,
|
||||||
|
firstName: match.node.contactName?.firstName ?? '',
|
||||||
|
lastName: match.node.contactName?.lastName ?? '',
|
||||||
|
patientId: match.node.patientId || null,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`[RESOLVE] Lead lookup failed: ${err.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findPatientByPhone(phone10: string, auth: string): Promise<{ id: string; firstName: string; lastName: string } | null> {
|
||||||
|
try {
|
||||||
|
const data = await this.platform.queryWithAuth<{ patients: { edges: { node: any }[] } }>(
|
||||||
|
`{ patients(first: 200) { edges { node {
|
||||||
|
id
|
||||||
|
fullName { firstName lastName }
|
||||||
|
phones { primaryPhoneNumber }
|
||||||
|
} } } }`,
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: match.node.id,
|
||||||
|
firstName: match.node.fullName?.firstName ?? '',
|
||||||
|
lastName: match.node.fullName?.lastName ?? '',
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`[RESOLVE] Patient lookup failed: ${err.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createPatient(firstName: string, lastName: string, phone: string, auth: string): Promise<{ id: string }> {
|
||||||
|
const data = await this.platform.queryWithAuth<{ createPatient: { id: string } }>(
|
||||||
|
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
fullName: { firstName: firstName || 'Unknown', lastName: lastName || '' },
|
||||||
|
phones: { primaryPhoneNumber: `+91${phone}` },
|
||||||
|
patientType: 'NEW',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auth,
|
||||||
|
);
|
||||||
|
return data.createPatient;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createLead(firstName: string, lastName: string, phone: string, patientId: string, auth: string): Promise<{ id: string }> {
|
||||||
|
const data = await this.platform.queryWithAuth<{ createLead: { id: string } }>(
|
||||||
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
name: `${firstName} ${lastName}`.trim() || 'Unknown Caller',
|
||||||
|
contactName: { firstName: firstName || 'Unknown', lastName: lastName || '' },
|
||||||
|
contactPhone: { primaryPhoneNumber: `+91${phone}` },
|
||||||
|
source: 'PHONE',
|
||||||
|
status: 'NEW',
|
||||||
|
patientId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auth,
|
||||||
|
);
|
||||||
|
return data.createLead;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 } }`,
|
||||||
|
{ id: leadId, data: { patientId } },
|
||||||
|
auth,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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' },
|
||||||
|
];
|
||||||
54
src/config/config-theme.module.ts
Normal file
54
src/config/config-theme.module.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
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({
|
||||||
|
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 {}
|
||||||
@@ -12,6 +12,13 @@ export default () => ({
|
|||||||
subdomain: process.env.EXOTEL_SUBDOMAIN ?? 'api.exotel.com',
|
subdomain: process.env.EXOTEL_SUBDOMAIN ?? 'api.exotel.com',
|
||||||
webhookSecret: process.env.EXOTEL_WEBHOOK_SECRET ?? '',
|
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: {
|
missedQueue: {
|
||||||
pollIntervalMs: parseInt(process.env.MISSED_QUEUE_POLL_INTERVAL_MS ?? '30000', 10),
|
pollIntervalMs: parseInt(process.env.MISSED_QUEUE_POLL_INTERVAL_MS ?? '30000', 10),
|
||||||
},
|
},
|
||||||
|
|||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
160
src/config/telephony-config.service.ts
Normal file
160
src/config/telephony-config.service.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
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***' : '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/config/telephony.defaults.ts
Normal file
76
src/config/telephony.defaults.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// 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 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: '',
|
||||||
|
},
|
||||||
|
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[] }> = [
|
||||||
|
{ env: 'OZONETEL_AGENT_ID', path: ['ozonetel', 'agentId'] },
|
||||||
|
{ env: 'OZONETEL_AGENT_PASSWORD', path: ['ozonetel', 'agentPassword'] },
|
||||||
|
{ 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'] },
|
||||||
|
];
|
||||||
27
src/config/theme.controller.ts
Normal file
27
src/config/theme.controller.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Controller, Get, Put, Post, Body, Logger } from '@nestjs/common';
|
||||||
|
import { ThemeService } from './theme.service';
|
||||||
|
import type { ThemeConfig } from './theme.defaults';
|
||||||
|
|
||||||
|
@Controller('api/config')
|
||||||
|
export class ThemeController {
|
||||||
|
private readonly logger = new Logger(ThemeController.name);
|
||||||
|
|
||||||
|
constructor(private readonly theme: ThemeService) {}
|
||||||
|
|
||||||
|
@Get('theme')
|
||||||
|
getTheme() {
|
||||||
|
return this.theme.getTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('theme')
|
||||||
|
updateTheme(@Body() body: Partial<ThemeConfig>) {
|
||||||
|
this.logger.log('Theme update request');
|
||||||
|
return this.theme.updateTheme(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('theme/reset')
|
||||||
|
resetTheme() {
|
||||||
|
this.logger.log('Theme reset request');
|
||||||
|
return this.theme.resetTheme();
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/config/theme.defaults.ts
Normal file
79
src/config/theme.defaults.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
export type ThemeConfig = {
|
||||||
|
version?: number;
|
||||||
|
updatedAt?: string;
|
||||||
|
brand: {
|
||||||
|
name: string;
|
||||||
|
hospitalName: string;
|
||||||
|
logo: string;
|
||||||
|
favicon: string;
|
||||||
|
};
|
||||||
|
colors: {
|
||||||
|
brand: Record<string, string>;
|
||||||
|
};
|
||||||
|
typography: {
|
||||||
|
body: string;
|
||||||
|
display: string;
|
||||||
|
};
|
||||||
|
login: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
showGoogleSignIn: boolean;
|
||||||
|
showForgotPassword: boolean;
|
||||||
|
poweredBy: { label: string; url: string };
|
||||||
|
};
|
||||||
|
sidebar: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
};
|
||||||
|
ai: {
|
||||||
|
quickActions: Array<{ label: string; prompt: string }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_THEME: ThemeConfig = {
|
||||||
|
brand: {
|
||||||
|
name: 'Helix Engage',
|
||||||
|
hospitalName: 'Global Hospital',
|
||||||
|
logo: '/helix-logo.png',
|
||||||
|
favicon: '/favicon.ico',
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
'25': 'rgb(239 246 255)',
|
||||||
|
'50': 'rgb(219 234 254)',
|
||||||
|
'100': 'rgb(191 219 254)',
|
||||||
|
'200': 'rgb(147 197 253)',
|
||||||
|
'300': 'rgb(96 165 250)',
|
||||||
|
'400': 'rgb(59 130 246)',
|
||||||
|
'500': 'rgb(37 99 235)',
|
||||||
|
'600': 'rgb(29 78 216)',
|
||||||
|
'700': 'rgb(30 64 175)',
|
||||||
|
'800': 'rgb(30 58 138)',
|
||||||
|
'900': 'rgb(23 37 84)',
|
||||||
|
'950': 'rgb(15 23 42)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
body: "'Satoshi', var(--font-inter, 'Inter'), -apple-system, 'Segoe UI', Roboto, Arial, sans-serif",
|
||||||
|
display: "'General Sans', var(--font-inter, 'Inter'), -apple-system, 'Segoe UI', Roboto, Arial, sans-serif",
|
||||||
|
},
|
||||||
|
login: {
|
||||||
|
title: 'Sign in to Helix Engage',
|
||||||
|
subtitle: 'Global Hospital',
|
||||||
|
showGoogleSignIn: true,
|
||||||
|
showForgotPassword: true,
|
||||||
|
poweredBy: { label: 'Powered by F0rty2.ai', url: 'https://f0rty2.ai' },
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
title: 'Helix Engage',
|
||||||
|
subtitle: 'Global Hospital \u00b7 {role}',
|
||||||
|
},
|
||||||
|
ai: {
|
||||||
|
quickActions: [
|
||||||
|
{ label: 'Doctor availability', prompt: 'What doctors are available and what are their visiting hours?' },
|
||||||
|
{ label: 'Clinic timings', prompt: 'What are the clinic locations and timings?' },
|
||||||
|
{ label: 'Patient history', prompt: "Can you summarize this patient's history?" },
|
||||||
|
{ label: 'Treatment packages', prompt: 'What treatment packages are available?' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
98
src/config/theme.service.ts
Normal file
98
src/config/theme.service.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'fs';
|
||||||
|
import { join, dirname } from 'path';
|
||||||
|
import { DEFAULT_THEME, type ThemeConfig } from './theme.defaults';
|
||||||
|
|
||||||
|
const THEME_PATH = join(process.cwd(), 'data', 'theme.json');
|
||||||
|
const BACKUP_DIR = join(process.cwd(), 'data', 'theme-backups');
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ThemeService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(ThemeService.name);
|
||||||
|
private cached: ThemeConfig | null = null;
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
getTheme(): ThemeConfig {
|
||||||
|
if (this.cached) return this.cached;
|
||||||
|
return this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTheme(updates: Partial<ThemeConfig>): ThemeConfig {
|
||||||
|
const current = this.getTheme();
|
||||||
|
|
||||||
|
const merged: ThemeConfig = {
|
||||||
|
brand: { ...current.brand, ...updates.brand },
|
||||||
|
colors: {
|
||||||
|
brand: { ...current.colors.brand, ...updates.colors?.brand },
|
||||||
|
},
|
||||||
|
typography: { ...current.typography, ...updates.typography },
|
||||||
|
login: { ...current.login, ...updates.login, poweredBy: { ...current.login.poweredBy, ...updates.login?.poweredBy } },
|
||||||
|
sidebar: { ...current.sidebar, ...updates.sidebar },
|
||||||
|
ai: {
|
||||||
|
quickActions: updates.ai?.quickActions ?? current.ai.quickActions,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
merged.version = (current.version ?? 0) + 1;
|
||||||
|
merged.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
this.backup();
|
||||||
|
|
||||||
|
const dir = dirname(THEME_PATH);
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(THEME_PATH, JSON.stringify(merged, null, 2), 'utf8');
|
||||||
|
this.cached = merged;
|
||||||
|
|
||||||
|
this.logger.log(`Theme updated to v${merged.version}`);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetTheme(): ThemeConfig {
|
||||||
|
this.backup();
|
||||||
|
const dir = dirname(THEME_PATH);
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(THEME_PATH, JSON.stringify(DEFAULT_THEME, null, 2), 'utf8');
|
||||||
|
this.cached = DEFAULT_THEME;
|
||||||
|
this.logger.log('Theme reset to defaults');
|
||||||
|
return DEFAULT_THEME;
|
||||||
|
}
|
||||||
|
|
||||||
|
private load(): ThemeConfig {
|
||||||
|
try {
|
||||||
|
if (existsSync(THEME_PATH)) {
|
||||||
|
const raw = readFileSync(THEME_PATH, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
this.cached = {
|
||||||
|
brand: { ...DEFAULT_THEME.brand, ...parsed.brand },
|
||||||
|
colors: { brand: { ...DEFAULT_THEME.colors.brand, ...parsed.colors?.brand } },
|
||||||
|
typography: { ...DEFAULT_THEME.typography, ...parsed.typography },
|
||||||
|
login: { ...DEFAULT_THEME.login, ...parsed.login, poweredBy: { ...DEFAULT_THEME.login.poweredBy, ...parsed.login?.poweredBy } },
|
||||||
|
sidebar: { ...DEFAULT_THEME.sidebar, ...parsed.sidebar },
|
||||||
|
ai: { quickActions: parsed.ai?.quickActions ?? DEFAULT_THEME.ai.quickActions },
|
||||||
|
};
|
||||||
|
this.logger.log('Theme loaded from file');
|
||||||
|
return this.cached;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to load theme: ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cached = DEFAULT_THEME;
|
||||||
|
this.logger.log('Using default theme');
|
||||||
|
return DEFAULT_THEME;
|
||||||
|
}
|
||||||
|
|
||||||
|
private backup() {
|
||||||
|
try {
|
||||||
|
if (!existsSync(THEME_PATH)) return;
|
||||||
|
if (!existsSync(BACKUP_DIR)) mkdirSync(BACKUP_DIR, { recursive: true });
|
||||||
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
copyFileSync(THEME_PATH, join(BACKUP_DIR, `theme-${ts}.json`));
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Backup failed: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
125
src/events/consumers/ai-insight.consumer.ts
Normal file
125
src/events/consumers/ai-insight.consumer.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { generateObject } from 'ai';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { EventBusService } from '../event-bus.service';
|
||||||
|
import { Topics } from '../event-types';
|
||||||
|
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 {
|
||||||
|
private readonly logger = new Logger(AiInsightConsumer.name);
|
||||||
|
private readonly aiModel: LanguageModel | null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private eventBus: EventBusService,
|
||||||
|
private platform: PlatformGraphqlService,
|
||||||
|
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'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
this.eventBus.on(Topics.CALL_COMPLETED, (event: CallCompletedEvent) => this.handleCallCompleted(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleCallCompleted(event: CallCompletedEvent): Promise<void> {
|
||||||
|
if (!event.leadId) {
|
||||||
|
this.logger.debug('[AI-INSIGHT] No leadId — skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.aiModel) {
|
||||||
|
this.logger.debug('[AI-INSIGHT] No AI model configured — skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[AI-INSIGHT] Generating insight for lead ${event.leadId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch lead + all activities
|
||||||
|
const data = await this.platform.query<any>(
|
||||||
|
`{ leads(filter: { id: { eq: "${event.leadId}" } }) { edges { node {
|
||||||
|
id name contactName { firstName lastName }
|
||||||
|
status source interestedService
|
||||||
|
contactAttempts lastContacted
|
||||||
|
} } } }`,
|
||||||
|
);
|
||||||
|
const lead = data?.leads?.edges?.[0]?.node;
|
||||||
|
if (!lead) return;
|
||||||
|
|
||||||
|
const activityData = await this.platform.query<any>(
|
||||||
|
`{ leadActivities(first: 20, filter: { leadId: { eq: "${event.leadId}" } }, orderBy: [{ occurredAt: DescNullsLast }]) {
|
||||||
|
edges { node { activityType summary occurredAt channel durationSec outcome } }
|
||||||
|
} }`,
|
||||||
|
);
|
||||||
|
const activities = activityData?.leadActivities?.edges?.map((e: any) => e.node) ?? [];
|
||||||
|
|
||||||
|
const leadName = lead.contactName
|
||||||
|
? `${lead.contactName.firstName ?? ''} ${lead.contactName.lastName ?? ''}`.trim()
|
||||||
|
: lead.name ?? 'Unknown';
|
||||||
|
|
||||||
|
// Build context
|
||||||
|
const activitySummary = activities.map((a: any) =>
|
||||||
|
`${a.activityType}: ${a.summary} (${a.occurredAt ?? 'unknown date'})`,
|
||||||
|
).join('\n');
|
||||||
|
|
||||||
|
// Generate insight
|
||||||
|
const { object } = await generateObject({
|
||||||
|
model: this.aiModel,
|
||||||
|
schema: z.object({
|
||||||
|
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: this.aiConfig.renderPrompt('callInsight', {
|
||||||
|
hospitalName: process.env.HOSPITAL_NAME ?? 'the hospital',
|
||||||
|
}),
|
||||||
|
prompt: `Lead: ${leadName}
|
||||||
|
Status: ${lead.status ?? 'Unknown'}
|
||||||
|
Source: ${lead.source ?? 'Unknown'}
|
||||||
|
Interested in: ${lead.interestedService ?? 'Not specified'}
|
||||||
|
Contact attempts: ${lead.contactAttempts ?? 0}
|
||||||
|
Last contacted: ${lead.lastContacted ?? 'Never'}
|
||||||
|
|
||||||
|
Recent activity (newest first):
|
||||||
|
${activitySummary || 'No activity recorded'}
|
||||||
|
|
||||||
|
Latest call:
|
||||||
|
- Direction: ${event.direction}
|
||||||
|
- Duration: ${event.durationSec}s
|
||||||
|
- Disposition: ${event.disposition}
|
||||||
|
- Notes: ${event.notes ?? 'None'}`,
|
||||||
|
maxOutputTokens: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update lead with new AI insight
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
id: event.leadId,
|
||||||
|
data: {
|
||||||
|
aiSummary: object.summary,
|
||||||
|
aiSuggestedAction: object.suggestedAction,
|
||||||
|
lastContacted: new Date().toISOString(),
|
||||||
|
contactAttempts: (lead.contactAttempts ?? 0) + 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`[AI-INSIGHT] Updated lead ${event.leadId}: "${object.summary.substring(0, 60)}..."`);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[AI-INSIGHT] Failed for lead ${event.leadId}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
114
src/events/event-bus.service.ts
Normal file
114
src/events/event-bus.service.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { Kafka, Producer, Consumer, EachMessagePayload } from 'kafkajs';
|
||||||
|
import type { EventPayload } from './event-types';
|
||||||
|
|
||||||
|
type EventHandler = (payload: any) => Promise<void>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EventBusService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(EventBusService.name);
|
||||||
|
private kafka: Kafka;
|
||||||
|
private producer: Producer;
|
||||||
|
private consumer: Consumer;
|
||||||
|
private handlers = new Map<string, EventHandler[]>();
|
||||||
|
private connected = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const brokers = (process.env.KAFKA_BROKERS ?? 'localhost:9092').split(',');
|
||||||
|
this.kafka = new Kafka({
|
||||||
|
clientId: 'helix-engage-sidecar',
|
||||||
|
brokers,
|
||||||
|
retry: { retries: 5, initialRetryTime: 1000 },
|
||||||
|
logLevel: 1, // ERROR only
|
||||||
|
});
|
||||||
|
this.producer = this.kafka.producer();
|
||||||
|
this.consumer = this.kafka.consumer({ groupId: 'helix-engage-workers' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
try {
|
||||||
|
await this.producer.connect();
|
||||||
|
await this.consumer.connect();
|
||||||
|
this.connected = true;
|
||||||
|
this.logger.log('Event bus connected (Kafka/Redpanda)');
|
||||||
|
|
||||||
|
// Subscribe to all topics we have handlers for
|
||||||
|
// Handlers are registered by consumer modules during their onModuleInit
|
||||||
|
// We start consuming after a short delay to let all handlers register
|
||||||
|
setTimeout(() => this.startConsuming(), 2000);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`Event bus not available (${err.message}) — running without events`);
|
||||||
|
this.connected = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
if (this.connected) {
|
||||||
|
await this.consumer.disconnect().catch(() => {});
|
||||||
|
await this.producer.disconnect().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async emit(topic: string, payload: EventPayload): Promise<void> {
|
||||||
|
if (!this.connected) {
|
||||||
|
this.logger.debug(`[EVENT] Skipped (not connected): ${topic}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.producer.send({
|
||||||
|
topic,
|
||||||
|
messages: [{ value: JSON.stringify(payload), timestamp: Date.now().toString() }],
|
||||||
|
});
|
||||||
|
this.logger.log(`[EVENT] Emitted: ${topic}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[EVENT] Failed to emit ${topic}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
on(topic: string, handler: EventHandler): void {
|
||||||
|
const existing = this.handlers.get(topic) ?? [];
|
||||||
|
existing.push(handler);
|
||||||
|
this.handlers.set(topic, existing);
|
||||||
|
this.logger.log(`[EVENT] Handler registered for: ${topic}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startConsuming(): Promise<void> {
|
||||||
|
if (!this.connected) return;
|
||||||
|
|
||||||
|
const topics = Array.from(this.handlers.keys());
|
||||||
|
if (topics.length === 0) {
|
||||||
|
this.logger.log('[EVENT] No handlers registered — skipping consumer');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const topic of topics) {
|
||||||
|
await this.consumer.subscribe({ topic, fromBeginning: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.consumer.run({
|
||||||
|
eachMessage: async (payload: EachMessagePayload) => {
|
||||||
|
const { topic, message } = payload;
|
||||||
|
const handlers = this.handlers.get(topic) ?? [];
|
||||||
|
if (handlers.length === 0 || !message.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(message.value.toString());
|
||||||
|
for (const handler of handlers) {
|
||||||
|
await handler(data).catch(err =>
|
||||||
|
this.logger.error(`[EVENT] Handler error on ${topic}: ${err.message}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[EVENT] Parse error on ${topic}: ${err.message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`[EVENT] Consuming: ${topics.join(', ')}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[EVENT] Consumer failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/events/event-types.ts
Normal file
36
src/events/event-types.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// Event topic names
|
||||||
|
export const Topics = {
|
||||||
|
CALL_COMPLETED: 'call.completed',
|
||||||
|
CALL_MISSED: 'call.missed',
|
||||||
|
AGENT_STATE: 'agent.state',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Event payloads
|
||||||
|
export type CallCompletedEvent = {
|
||||||
|
callId: string | null;
|
||||||
|
ucid: string;
|
||||||
|
agentId: string;
|
||||||
|
callerPhone: string;
|
||||||
|
direction: string;
|
||||||
|
durationSec: number;
|
||||||
|
disposition: string;
|
||||||
|
leadId: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CallMissedEvent = {
|
||||||
|
callId: string | null;
|
||||||
|
callerPhone: string;
|
||||||
|
leadId: string | null;
|
||||||
|
leadName: string | null;
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentStateEvent = {
|
||||||
|
agentId: string;
|
||||||
|
state: string;
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EventPayload = CallCompletedEvent | CallMissedEvent | AgentStateEvent;
|
||||||
12
src/events/events.module.ts
Normal file
12
src/events/events.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module, Global } from '@nestjs/common';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
import { EventBusService } from './event-bus.service';
|
||||||
|
import { AiInsightConsumer } from './consumers/ai-insight.consumer';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [PlatformModule],
|
||||||
|
providers: [EventBusService, AiInsightConsumer],
|
||||||
|
exports: [EventBusService],
|
||||||
|
})
|
||||||
|
export class EventsModule {}
|
||||||
279
src/livekit-agent/agent.ts
Normal file
279
src/livekit-agent/agent.ts
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import { WorkerOptions, defineAgent, llm, voice, VAD } from '@livekit/agents';
|
||||||
|
import * as google from '@livekit/agents-plugin-google';
|
||||||
|
import * as silero from '@livekit/agents-plugin-silero';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// Platform GraphQL helper
|
||||||
|
const SIDECAR_URL = process.env.SIDECAR_URL ?? 'http://localhost:4100';
|
||||||
|
const PLATFORM_API_KEY = process.env.PLATFORM_API_KEY ?? '';
|
||||||
|
|
||||||
|
async function gql<T = any>(query: string, variables?: Record<string, unknown>): Promise<T | null> {
|
||||||
|
if (!PLATFORM_API_KEY) return null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${SIDECAR_URL}/graphql`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${PLATFORM_API_KEY}` },
|
||||||
|
body: JSON.stringify({ query, variables }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.errors) {
|
||||||
|
console.error('[AGENT-GQL] Error:', data.errors[0]?.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return data.data;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[AGENT-GQL] Failed:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hospital context — loaded on startup
|
||||||
|
let hospitalContext = {
|
||||||
|
doctors: [] as Array<{ name: string; department: string; specialty: string; id: string }>,
|
||||||
|
departments: [] as string[],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadHospitalContext() {
|
||||||
|
const data = await gql(`{ doctors(first: 20) { edges { node { id fullName { firstName lastName } department specialty } } } }`);
|
||||||
|
if (data?.doctors?.edges) {
|
||||||
|
hospitalContext.doctors = data.doctors.edges.map((e: any) => ({
|
||||||
|
id: e.node.id,
|
||||||
|
name: `Dr. ${e.node.fullName?.firstName ?? ''} ${e.node.fullName?.lastName ?? ''}`.trim(),
|
||||||
|
department: e.node.department ?? '',
|
||||||
|
specialty: e.node.specialty ?? '',
|
||||||
|
}));
|
||||||
|
hospitalContext.departments = [...new Set(hospitalContext.doctors.map(d => d.department))] as string[];
|
||||||
|
console.log(`[LIVEKIT-AGENT] Loaded ${hospitalContext.doctors.length} doctors, ${hospitalContext.departments.length} departments`);
|
||||||
|
} else {
|
||||||
|
// Fallback
|
||||||
|
hospitalContext.doctors = [
|
||||||
|
{ id: '', name: 'Dr. Arun Sharma', department: 'Cardiology', specialty: 'Interventional Cardiology' },
|
||||||
|
{ id: '', name: 'Dr. Rajesh Kumar', department: 'Orthopedics', specialty: 'Joint Replacement' },
|
||||||
|
{ id: '', name: 'Dr. Meena Patel', department: 'Gynecology', specialty: 'Reproductive Medicine' },
|
||||||
|
{ id: '', name: 'Dr. Lakshmi Reddy', department: 'General Medicine', specialty: 'Internal Medicine' },
|
||||||
|
{ id: '', name: 'Dr. Harpreet Singh', department: 'ENT', specialty: 'Head & Neck Surgery' },
|
||||||
|
];
|
||||||
|
hospitalContext.departments = ['Cardiology', 'Orthopedics', 'Gynecology', 'General Medicine', 'ENT'];
|
||||||
|
console.log('[LIVEKIT-AGENT] Using fallback doctor list');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tools ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const lookupDoctor = llm.tool({
|
||||||
|
description: 'Look up available doctors by department or specialty. Call this when the patient asks about a specific department or type of doctor.',
|
||||||
|
parameters: z.object({
|
||||||
|
department: z.string().nullable().describe('Department name like Cardiology, Orthopedics, ENT'),
|
||||||
|
specialty: z.string().nullable().describe('Specialty or condition like joint pain, heart, ear'),
|
||||||
|
}),
|
||||||
|
execute: async ({ department, specialty }) => {
|
||||||
|
let results = hospitalContext.doctors;
|
||||||
|
if (department) {
|
||||||
|
results = results.filter(d => d.department.toLowerCase().includes(department.toLowerCase()));
|
||||||
|
}
|
||||||
|
if (specialty) {
|
||||||
|
results = results.filter(d =>
|
||||||
|
d.specialty.toLowerCase().includes(specialty.toLowerCase()) ||
|
||||||
|
d.department.toLowerCase().includes(specialty.toLowerCase()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (results.length === 0) return 'No matching doctors found. Available departments: ' + hospitalContext.departments.join(', ');
|
||||||
|
return results.map(d => `${d.name} — ${d.department} (${d.specialty})`).join('\n');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const bookAppointment = llm.tool({
|
||||||
|
description: 'Book an appointment for the caller. You MUST collect patient name, phone number, department, preferred date/time, and reason before calling this.',
|
||||||
|
parameters: z.object({
|
||||||
|
patientName: z.string().describe('Full name of the patient'),
|
||||||
|
phoneNumber: z.string().describe('Patient phone number with country code'),
|
||||||
|
department: z.string().describe('Department for the appointment'),
|
||||||
|
doctorName: z.string().nullable().describe('Preferred doctor name if specified'),
|
||||||
|
preferredDate: z.string().describe('Date in YYYY-MM-DD format or natural language'),
|
||||||
|
preferredTime: z.string().describe('Time slot like 10:00 AM, morning, afternoon'),
|
||||||
|
reason: z.string().describe('Reason for visit'),
|
||||||
|
}),
|
||||||
|
execute: async ({ patientName, phoneNumber, department, doctorName, preferredDate, preferredTime, reason }) => {
|
||||||
|
console.log(`[LIVEKIT-AGENT] Booking: ${patientName} | ${phoneNumber} | ${department} | ${doctorName ?? 'any'} | ${preferredDate} ${preferredTime}`);
|
||||||
|
|
||||||
|
// Parse date — try ISO format first, fallback to tomorrow
|
||||||
|
let scheduledAt: string;
|
||||||
|
try {
|
||||||
|
const parsed = new Date(preferredDate);
|
||||||
|
if (!isNaN(parsed.getTime())) {
|
||||||
|
// Map time to hour
|
||||||
|
const timeMap: Record<string, string> = { morning: '10:00', afternoon: '14:00', evening: '17:00' };
|
||||||
|
const timeStr = timeMap[preferredTime.toLowerCase()] ?? preferredTime.replace(/\s*(AM|PM)/i, (_, p) => '');
|
||||||
|
scheduledAt = new Date(`${parsed.toISOString().split('T')[0]}T${timeStr}:00`).toISOString();
|
||||||
|
} else {
|
||||||
|
scheduledAt = new Date(Date.now() + 86400000).toISOString(); // tomorrow
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
scheduledAt = new Date(Date.now() + 86400000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find matching doctor
|
||||||
|
const doctor = doctorName
|
||||||
|
? hospitalContext.doctors.find(d => d.name.toLowerCase().includes(doctorName.toLowerCase()))
|
||||||
|
: hospitalContext.doctors.find(d => d.department.toLowerCase().includes(department.toLowerCase()));
|
||||||
|
|
||||||
|
// Create appointment on platform
|
||||||
|
const result = await gql(
|
||||||
|
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
name: `AI Booking — ${patientName} (${department})`,
|
||||||
|
scheduledAt,
|
||||||
|
status: 'SCHEDULED',
|
||||||
|
doctorName: doctor?.name ?? doctorName ?? 'To be assigned',
|
||||||
|
department,
|
||||||
|
reasonForVisit: reason,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create or find lead
|
||||||
|
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(' ') || '',
|
||||||
|
},
|
||||||
|
contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` },
|
||||||
|
source: 'PHONE',
|
||||||
|
status: 'APPOINTMENT_SET',
|
||||||
|
interestedService: department,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const refNum = `GH-${Date.now().toString().slice(-6)}`;
|
||||||
|
if (result?.createAppointment?.id) {
|
||||||
|
console.log(`[LIVEKIT-AGENT] Appointment created: ${result.createAppointment.id}`);
|
||||||
|
return `Appointment booked successfully! Reference number ${refNum}. ${patientName} is scheduled for ${department} on ${preferredDate} at ${preferredTime} with ${doctor?.name ?? 'an available doctor'}. A confirmation SMS will be sent to ${phoneNumber}.`;
|
||||||
|
}
|
||||||
|
return `I have noted the appointment request. Reference number ${refNum}. Our team will confirm the booking and send an SMS to ${phoneNumber}.`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const collectLeadInfo = llm.tool({
|
||||||
|
description: 'Save the caller as a lead/enquiry when they are interested but not ready to book. Collect their name and phone number.',
|
||||||
|
parameters: z.object({
|
||||||
|
name: z.string().describe('Caller name'),
|
||||||
|
phoneNumber: z.string().describe('Caller phone number'),
|
||||||
|
interest: z.string().describe('What they are interested in or enquiring about'),
|
||||||
|
}),
|
||||||
|
execute: async ({ name, phoneNumber, interest }) => {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result?.createLead?.id) {
|
||||||
|
console.log(`[LIVEKIT-AGENT] Lead created: ${result.createLead.id}`);
|
||||||
|
}
|
||||||
|
return `Thank you ${name}. I have noted your enquiry about ${interest}. One of our team members will call you back on ${phoneNumber} shortly.`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const transferToAgent = llm.tool({
|
||||||
|
description: 'Transfer the call to a human agent. Use this when the caller explicitly asks to speak with a person, or when the query is too complex.',
|
||||||
|
parameters: z.object({
|
||||||
|
reason: z.string().describe('Why the caller needs a human agent'),
|
||||||
|
}),
|
||||||
|
execute: async ({ reason }) => {
|
||||||
|
console.log(`[LIVEKIT-AGENT] Transfer requested: ${reason}`);
|
||||||
|
// TODO: When SIP is connected, trigger Ozonetel transfer via sidecar API
|
||||||
|
return 'I am transferring you to one of our agents now. Please hold for a moment. If no agent is available, someone will call you back within 15 minutes.';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Agent ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const hospitalAgent = new voice.Agent({
|
||||||
|
instructions: `You are the AI receptionist for Global Hospital, Bangalore. Your name is Helix.
|
||||||
|
|
||||||
|
PERSONALITY:
|
||||||
|
- Warm, professional, and empathetic
|
||||||
|
- Speak clearly and at a moderate pace
|
||||||
|
- Use simple language — many callers may not be fluent in English
|
||||||
|
- Be concise — this is a phone call, not a chat
|
||||||
|
- Respond in the same language the caller uses (English, Hindi, Kannada)
|
||||||
|
|
||||||
|
CAPABILITIES:
|
||||||
|
- Answer questions about hospital departments, doctors, and specialties
|
||||||
|
- Book appointments — collect: name, phone, department, preferred date/time, reason
|
||||||
|
- Take messages and create enquiries for callback
|
||||||
|
- Transfer to a human agent when needed
|
||||||
|
|
||||||
|
HOSPITAL INFO:
|
||||||
|
- Global Hospital, Bangalore
|
||||||
|
- Open Monday to Saturday, 8 AM to 8 PM
|
||||||
|
- Emergency services available 24/7
|
||||||
|
- Departments: ${hospitalContext.departments.join(', ') || 'Cardiology, Orthopedics, Gynecology, General Medicine, ENT'}
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- Greet: "Hello, thank you for calling Global Hospital. This is Helix, how may I help you today?"
|
||||||
|
- If caller asks about pricing, say you will have the team call back with details
|
||||||
|
- Never give medical advice — always recommend consulting a doctor
|
||||||
|
- If the caller is in an emergency, tell them to visit the ER immediately or call 108
|
||||||
|
- Always confirm all details before booking an appointment
|
||||||
|
- End calls politely: "Thank you for calling Global Hospital. Have a good day!"
|
||||||
|
- If you cannot understand the caller, politely ask them to repeat`,
|
||||||
|
llm: new google.beta.realtime.RealtimeModel({
|
||||||
|
model: 'gemini-2.5-flash-native-audio-latest',
|
||||||
|
voice: 'Aoede',
|
||||||
|
temperature: 0.7,
|
||||||
|
}),
|
||||||
|
tools: { lookupDoctor, bookAppointment, collectLeadInfo, transferToAgent },
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Entry Point ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default defineAgent({
|
||||||
|
prewarm: async (proc) => {
|
||||||
|
proc.userData.vad = await silero.VAD.load();
|
||||||
|
await loadHospitalContext();
|
||||||
|
},
|
||||||
|
entry: async (ctx) => {
|
||||||
|
await ctx.connect();
|
||||||
|
console.log(`[LIVEKIT-AGENT] Connected to room: ${ctx.room.name}`);
|
||||||
|
|
||||||
|
const session = new voice.AgentSession({
|
||||||
|
vad: ctx.proc.userData.vad as VAD,
|
||||||
|
});
|
||||||
|
|
||||||
|
await session.start({ agent: hospitalAgent, room: ctx.room });
|
||||||
|
console.log('[LIVEKIT-AGENT] Voice session started');
|
||||||
|
|
||||||
|
// Gemini Realtime handles greeting via instructions — no separate say() needed
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// CLI runner
|
||||||
|
if (require.main === module) {
|
||||||
|
const options = new WorkerOptions({
|
||||||
|
agent: __filename,
|
||||||
|
});
|
||||||
|
const { cli } = require('@livekit/agents');
|
||||||
|
cli.runApp(options);
|
||||||
|
}
|
||||||
15
src/main.ts
15
src/main.ts
@@ -1,9 +1,11 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
|
import { join } from 'path';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||||
const config = app.get(ConfigService);
|
const config = app.get(ConfigService);
|
||||||
|
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
@@ -11,6 +13,17 @@ async function bootstrap() {
|
|||||||
credentials: true,
|
credentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Serve widget.js and other static files from /public
|
||||||
|
// In dev mode __dirname = src/, in prod __dirname = dist/ — resolve from process.cwd()
|
||||||
|
app.useStaticAssets(join(process.cwd(), 'public'), {
|
||||||
|
setHeaders: (res, path) => {
|
||||||
|
if (path.endsWith('.js')) {
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const port = config.get('port');
|
const port = config.get('port');
|
||||||
await app.listen(port);
|
await app.listen(port);
|
||||||
console.log(`Helix Engage Server running on port ${port}`);
|
console.log(`Helix Engage Server running on port ${port}`);
|
||||||
|
|||||||
316
src/maint/maint.controller.ts
Normal file
316
src/maint/maint.controller.ts
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
import { Controller, 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 { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||||
|
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||||
|
|
||||||
|
@Controller('api/maint')
|
||||||
|
@UseGuards(MaintGuard)
|
||||||
|
export class MaintController {
|
||||||
|
private readonly logger = new Logger(MaintController.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly telephony: TelephonyConfigService,
|
||||||
|
private readonly ozonetel: OzonetelAgentService,
|
||||||
|
private readonly platform: PlatformGraphqlService,
|
||||||
|
private readonly session: SessionService,
|
||||||
|
private readonly supervisor: SupervisorService,
|
||||||
|
private readonly callerResolution: CallerResolutionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post('force-ready')
|
||||||
|
async forceReady() {
|
||||||
|
const oz = this.telephony.getConfig().ozonetel;
|
||||||
|
const agentId = oz.agentId || 'agent3';
|
||||||
|
const password = oz.agentPassword || 'Test123$';
|
||||||
|
const sipId = oz.sipId || '521814';
|
||||||
|
|
||||||
|
this.logger.log(`[MAINT] Force ready: agent=${agentId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.ozonetel.logoutAgent({ agentId, password });
|
||||||
|
const result = await this.ozonetel.loginAgent({
|
||||||
|
agentId,
|
||||||
|
password,
|
||||||
|
phoneNumber: sipId,
|
||||||
|
mode: 'blended',
|
||||||
|
});
|
||||||
|
this.logger.log(`[MAINT] Force ready complete: ${JSON.stringify(result)}`);
|
||||||
|
return { status: 'ok', message: `Agent ${agentId} force-readied`, result };
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = error.response?.data?.message ?? error.message ?? 'Force ready failed';
|
||||||
|
this.logger.error(`[MAINT] Force ready failed: ${message}`);
|
||||||
|
return { status: 'error', message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('unlock-agent')
|
||||||
|
async unlockAgent() {
|
||||||
|
const agentId = this.telephony.getConfig().ozonetel.agentId || 'agent3';
|
||||||
|
this.logger.log(`[MAINT] Unlock agent session: ${agentId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await this.session.getSession(agentId);
|
||||||
|
if (!existing) {
|
||||||
|
return { status: 'ok', message: `No active session for ${agentId}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.session.unlockSession(agentId);
|
||||||
|
|
||||||
|
// Push force-logout via SSE to all connected browsers for this agent
|
||||||
|
this.supervisor.emitForceLogout(agentId);
|
||||||
|
|
||||||
|
this.logger.log(`[MAINT] Session unlocked + force-logout pushed for ${agentId} (was held by IP ${existing.ip} since ${existing.lockedAt})`);
|
||||||
|
return { status: 'ok', message: `Session unlocked and force-logout sent for ${agentId}`, previousSession: existing };
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`[MAINT] Unlock failed: ${error.message}`);
|
||||||
|
return { status: 'error', message: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('backfill-missed-calls')
|
||||||
|
async backfillMissedCalls() {
|
||||||
|
this.logger.log('[MAINT] Backfill missed call lead names — starting');
|
||||||
|
|
||||||
|
// Fetch all missed calls without a leadId
|
||||||
|
const result = await this.platform.query<any>(
|
||||||
|
`{ calls(first: 200, filter: {
|
||||||
|
callStatus: { eq: MISSED },
|
||||||
|
leadId: { is: NULL }
|
||||||
|
}) { edges { node { id callerNumber { primaryPhoneNumber } } } } }`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const calls = result?.calls?.edges?.map((e: any) => e.node) ?? [];
|
||||||
|
if (calls.length === 0) {
|
||||||
|
this.logger.log('[MAINT] No missed calls without leadId found');
|
||||||
|
return { status: 'ok', total: 0, patched: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[MAINT] Found ${calls.length} missed calls without leadId`);
|
||||||
|
|
||||||
|
let patched = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for (const call of calls) {
|
||||||
|
const phone = call.callerNumber?.primaryPhoneNumber;
|
||||||
|
if (!phone) { skipped++; continue; }
|
||||||
|
|
||||||
|
const phoneDigits = phone.replace(/^\+91/, '');
|
||||||
|
try {
|
||||||
|
const leadResult = await this.platform.query<any>(
|
||||||
|
`{ leads(first: 1, filter: {
|
||||||
|
contactPhone: { primaryPhoneNumber: { like: "%${phoneDigits}" } }
|
||||||
|
}) { edges { node { id contactName { firstName lastName } } } } }`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const lead = leadResult?.leads?.edges?.[0]?.node;
|
||||||
|
if (!lead) { skipped++; continue; }
|
||||||
|
|
||||||
|
const fn = lead.contactName?.firstName ?? '';
|
||||||
|
const ln = lead.contactName?.lastName ?? '';
|
||||||
|
const leadName = `${fn} ${ln}`.trim();
|
||||||
|
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation { updateCall(id: "${call.id}", data: {
|
||||||
|
leadId: "${lead.id}"${leadName ? `, leadName: "${leadName}"` : ''}
|
||||||
|
}) { id } }`,
|
||||||
|
);
|
||||||
|
|
||||||
|
patched++;
|
||||||
|
this.logger.log(`[MAINT] Patched ${phone} → ${leadName} (${lead.id})`);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[MAINT] Failed to patch ${call.id}: ${err}`);
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[MAINT] Backfill complete: ${patched} patched, ${skipped} skipped out of ${calls.length}`);
|
||||||
|
return { status: 'ok', total: calls.length, patched, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('fix-timestamps')
|
||||||
|
async fixTimestamps() {
|
||||||
|
this.logger.log('[MAINT] Fix call timestamps — subtracting 5:30 IST offset from existing records');
|
||||||
|
|
||||||
|
const result = await this.platform.query<any>(
|
||||||
|
`{ calls(first: 200) { edges { node { id startedAt endedAt createdAt } } } }`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const calls = result?.calls?.edges?.map((e: any) => e.node) ?? [];
|
||||||
|
if (calls.length === 0) {
|
||||||
|
return { status: 'ok', total: 0, fixed: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[MAINT] Found ${calls.length} call records to check`);
|
||||||
|
|
||||||
|
let fixed = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for (const call of calls) {
|
||||||
|
if (!call.startedAt) { skipped++; continue; }
|
||||||
|
|
||||||
|
// Skip records that don't need fixing: if startedAt is BEFORE createdAt,
|
||||||
|
// it was already corrected (or is naturally correct)
|
||||||
|
const started = new Date(call.startedAt).getTime();
|
||||||
|
const created = new Date(call.createdAt).getTime();
|
||||||
|
if (started <= created) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updates: string[] = [];
|
||||||
|
|
||||||
|
const startDate = new Date(call.startedAt);
|
||||||
|
startDate.setMinutes(startDate.getMinutes() - 330);
|
||||||
|
updates.push(`startedAt: "${startDate.toISOString()}"`);
|
||||||
|
|
||||||
|
if (call.endedAt) {
|
||||||
|
const endDate = new Date(call.endedAt);
|
||||||
|
endDate.setMinutes(endDate.getMinutes() - 330);
|
||||||
|
updates.push(`endedAt: "${endDate.toISOString()}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation { updateCall(id: "${call.id}", data: { ${updates.join(', ')} }) { id } }`,
|
||||||
|
);
|
||||||
|
|
||||||
|
fixed++;
|
||||||
|
|
||||||
|
// Throttle: 700ms between mutations to stay under 100/min rate limit
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 700));
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[MAINT] Failed to fix ${call.id}: ${err}`);
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[MAINT] Timestamp fix complete: ${fixed} fixed, ${skipped} skipped out of ${calls.length}`);
|
||||||
|
return { status: 'ok', total: calls.length, fixed, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('clear-analysis-cache')
|
||||||
|
async clearAnalysisCache() {
|
||||||
|
this.logger.log('[MAINT] Clearing all recording analysis cache');
|
||||||
|
const keys = await this.session.scanKeys('call:analysis:*');
|
||||||
|
let cleared = 0;
|
||||||
|
for (const key of keys) {
|
||||||
|
await this.session.deleteCache(key);
|
||||||
|
cleared++;
|
||||||
|
}
|
||||||
|
this.logger.log(`[MAINT] Cleared ${cleared} analysis cache entries`);
|
||||||
|
return { status: 'ok', cleared };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('backfill-lead-patient-links')
|
||||||
|
async backfillLeadPatientLinks() {
|
||||||
|
this.logger.log('[MAINT] Backfill lead-patient links — matching by phone number');
|
||||||
|
|
||||||
|
// Fetch all leads
|
||||||
|
const leadResult = await this.platform.query<any>(
|
||||||
|
`{ leads(first: 200) { edges { node { id patientId contactPhone { primaryPhoneNumber } contactName { firstName lastName } } } } }`,
|
||||||
|
);
|
||||||
|
const leads = leadResult?.leads?.edges?.map((e: any) => e.node) ?? [];
|
||||||
|
|
||||||
|
// Fetch all patients
|
||||||
|
const patientResult = await this.platform.query<any>(
|
||||||
|
`{ patients(first: 200) { edges { node { id phones { primaryPhoneNumber } fullName { firstName lastName } } } } }`,
|
||||||
|
);
|
||||||
|
const patients = patientResult?.patients?.edges?.map((e: any) => e.node) ?? [];
|
||||||
|
|
||||||
|
// Build patient phone → id map
|
||||||
|
const patientByPhone = new Map<string, { id: string; firstName: string; lastName: string }>();
|
||||||
|
for (const p of patients) {
|
||||||
|
const phone = (p.phones?.primaryPhoneNumber ?? '').replace(/\D/g, '').slice(-10);
|
||||||
|
if (phone.length === 10) {
|
||||||
|
patientByPhone.set(phone, {
|
||||||
|
id: p.id,
|
||||||
|
firstName: p.fullName?.firstName ?? '',
|
||||||
|
lastName: p.fullName?.lastName ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let linked = 0;
|
||||||
|
let created = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for (const lead of leads) {
|
||||||
|
const phone = (lead.contactPhone?.primaryPhoneNumber ?? '').replace(/\D/g, '').slice(-10);
|
||||||
|
if (!phone || phone.length < 10) { skipped++; continue; }
|
||||||
|
|
||||||
|
if (lead.patientId) { skipped++; continue; } // already linked
|
||||||
|
|
||||||
|
const matchedPatient = patientByPhone.get(phone);
|
||||||
|
|
||||||
|
if (matchedPatient) {
|
||||||
|
// Patient exists — link lead to patient
|
||||||
|
try {
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation { updateLead(id: "${lead.id}", data: { patientId: "${matchedPatient.id}" }) { id } }`,
|
||||||
|
);
|
||||||
|
linked++;
|
||||||
|
this.logger.log(`[MAINT] Linked lead ${lead.id} → patient ${matchedPatient.id} (${phone})`);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[MAINT] Failed to link lead ${lead.id}: ${err}`);
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No patient — create one from lead data
|
||||||
|
try {
|
||||||
|
const firstName = lead.contactName?.firstName ?? 'Unknown';
|
||||||
|
const lastName = lead.contactName?.lastName ?? '';
|
||||||
|
const result = await this.platform.query<any>(
|
||||||
|
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
fullName: { firstName, lastName },
|
||||||
|
phones: { primaryPhoneNumber: `+91${phone}` },
|
||||||
|
patientType: 'NEW',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const newPatientId = result?.createPatient?.id;
|
||||||
|
if (newPatientId) {
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation { updateLead(id: "${lead.id}", data: { patientId: "${newPatientId}" }) { id } }`,
|
||||||
|
);
|
||||||
|
patientByPhone.set(phone, { id: newPatientId, firstName, lastName });
|
||||||
|
created++;
|
||||||
|
this.logger.log(`[MAINT] Created patient ${newPatientId} + linked to lead ${lead.id} (${phone})`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[MAINT] Failed to create patient for lead ${lead.id}: ${err}`);
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throttle
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now backfill appointments — link to patient via lead
|
||||||
|
const apptResult = await this.platform.query<any>(
|
||||||
|
`{ appointments(first: 200) { edges { node { id patientId createdAt } } } }`,
|
||||||
|
);
|
||||||
|
const appointments = apptResult?.appointments?.edges?.map((e: any) => e.node) ?? [];
|
||||||
|
|
||||||
|
let apptLinked = 0;
|
||||||
|
// For appointments without patientId, find the lead that was active around that time
|
||||||
|
// and use its patientId. This is best-effort.
|
||||||
|
for (const appt of appointments) {
|
||||||
|
if (appt.patientId) continue;
|
||||||
|
|
||||||
|
// Find the most recent lead that has a patientId (best-effort match)
|
||||||
|
// In practice, for the current data set this is sufficient
|
||||||
|
// A proper fix would store leadId on the appointment
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[MAINT] Backfill complete: ${linked} linked, ${created} patients created, ${apptLinked} appointments linked, ${skipped} skipped`);
|
||||||
|
return { status: 'ok', leads: { total: leads.length, linked, created, skipped }, appointments: { total: appointments.length, linked: apptLinked } };
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/maint/maint.guard.ts
Normal file
20
src/maint/maint.guard.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { CanActivate, ExecutionContext, Injectable, HttpException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MaintGuard implements CanActivate {
|
||||||
|
private readonly otp: string;
|
||||||
|
|
||||||
|
constructor(private config: ConfigService) {
|
||||||
|
this.otp = process.env.MAINT_OTP ?? '400168';
|
||||||
|
}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const provided = request.headers['x-maint-otp'] ?? request.body?.otp;
|
||||||
|
if (!provided || provided !== this.otp) {
|
||||||
|
throw new HttpException('Invalid maintenance OTP', 403);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/maint/maint.module.ts
Normal file
13
src/maint/maint.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { SupervisorModule } from '../supervisor/supervisor.module';
|
||||||
|
import { CallerResolutionModule } from '../caller/caller-resolution.module';
|
||||||
|
import { MaintController } from './maint.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PlatformModule, OzonetelAgentModule, AuthModule, SupervisorModule, CallerResolutionModule],
|
||||||
|
controllers: [MaintController],
|
||||||
|
})
|
||||||
|
export class MaintModule {}
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
import { Controller, Get, Query, Logger, Header } from '@nestjs/common';
|
import { Controller, Get, Query, Logger, Header } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||||
|
|
||||||
@Controller('kookoo')
|
@Controller('kookoo')
|
||||||
export class KookooIvrController {
|
export class KookooIvrController {
|
||||||
private readonly logger = new Logger(KookooIvrController.name);
|
private readonly logger = new Logger(KookooIvrController.name);
|
||||||
private readonly sipId: string;
|
|
||||||
private readonly callerId: string;
|
|
||||||
|
|
||||||
constructor(private config: ConfigService) {
|
constructor(private telephony: TelephonyConfigService) {}
|
||||||
this.sipId = process.env.OZONETEL_SIP_ID ?? '523590';
|
|
||||||
this.callerId = process.env.OZONETEL_DID ?? '918041763265';
|
private get sipId(): string {
|
||||||
|
return this.telephony.getConfig().ozonetel.sipId || '523590';
|
||||||
|
}
|
||||||
|
private get callerId(): string {
|
||||||
|
return this.telephony.getConfig().ozonetel.did || '918041763265';
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('ivr')
|
@Get('ivr')
|
||||||
|
|||||||
@@ -1,26 +1,32 @@
|
|||||||
import { Controller, Post, Get, Body, Query, Logger, HttpException } from '@nestjs/common';
|
import { Controller, Post, Get, Body, Query, Logger, HttpException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { OzonetelAgentService } from './ozonetel-agent.service';
|
import { OzonetelAgentService } from './ozonetel-agent.service';
|
||||||
import { MissedQueueService } from '../worklist/missed-queue.service';
|
import { MissedQueueService } from '../worklist/missed-queue.service';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { EventBusService } from '../events/event-bus.service';
|
||||||
|
import { Topics } from '../events/event-types';
|
||||||
|
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||||
|
|
||||||
@Controller('api/ozonetel')
|
@Controller('api/ozonetel')
|
||||||
export class OzonetelAgentController {
|
export class OzonetelAgentController {
|
||||||
private readonly logger = new Logger(OzonetelAgentController.name);
|
private readonly logger = new Logger(OzonetelAgentController.name);
|
||||||
private readonly defaultAgentId: string;
|
|
||||||
private readonly defaultAgentPassword: string;
|
|
||||||
|
|
||||||
private readonly defaultSipId: string;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly ozonetelAgent: OzonetelAgentService,
|
private readonly ozonetelAgent: OzonetelAgentService,
|
||||||
private readonly config: ConfigService,
|
private readonly telephony: TelephonyConfigService,
|
||||||
private readonly missedQueue: MissedQueueService,
|
private readonly missedQueue: MissedQueueService,
|
||||||
private readonly platform: PlatformGraphqlService,
|
private readonly platform: PlatformGraphqlService,
|
||||||
) {
|
private readonly eventBus: EventBusService,
|
||||||
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';
|
// Read-through accessors so admin updates take effect immediately.
|
||||||
|
private get defaultAgentId(): string {
|
||||||
|
return this.telephony.getConfig().ozonetel.agentId || 'agent3';
|
||||||
|
}
|
||||||
|
private get defaultAgentPassword(): string {
|
||||||
|
return this.telephony.getConfig().ozonetel.agentPassword;
|
||||||
|
}
|
||||||
|
private get defaultSipId(): string {
|
||||||
|
return this.telephony.getConfig().ozonetel.sipId || '521814';
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('agent-login')
|
@Post('agent-login')
|
||||||
@@ -65,7 +71,7 @@ export class OzonetelAgentController {
|
|||||||
throw new HttpException('state required', 400);
|
throw new HttpException('state required', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`Agent state change: ${this.defaultAgentId} → ${body.state} (${body.pauseReason ?? ''})`);
|
this.logger.log(`[AGENT-STATE] ${this.defaultAgentId} → ${body.state} (${body.pauseReason ?? 'none'})`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.ozonetelAgent.changeAgentState({
|
const result = await this.ozonetelAgent.changeAgentState({
|
||||||
@@ -73,47 +79,31 @@ export class OzonetelAgentController {
|
|||||||
state: body.state,
|
state: body.state,
|
||||||
pauseReason: body.pauseReason,
|
pauseReason: body.pauseReason,
|
||||||
});
|
});
|
||||||
|
this.logger.log(`[AGENT-STATE] Ozonetel response: ${JSON.stringify(result)}`);
|
||||||
|
|
||||||
|
// Auto-assign missed call when agent goes Ready
|
||||||
|
if (body.state === 'Ready') {
|
||||||
|
try {
|
||||||
|
const assigned = await this.missedQueue.assignNext(this.defaultAgentId);
|
||||||
|
if (assigned) {
|
||||||
|
this.logger.log(`[AGENT-STATE] Auto-assigned missed call ${assigned.id}`);
|
||||||
|
return { ...result, assignedCall: assigned };
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[AGENT-STATE] Auto-assignment on Ready failed: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const message = error.response?.data?.message ?? error.message ?? 'State change failed';
|
const message = error.response?.data?.message ?? error.message ?? 'State change failed';
|
||||||
|
const responseData = error.response?.data ? JSON.stringify(error.response.data) : '';
|
||||||
|
this.logger.error(`[AGENT-STATE] FAILED: ${message} ${responseData}`);
|
||||||
return { status: 'error', message };
|
return { status: 'error', message };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-assign missed call when agent goes Ready
|
|
||||||
if (body.state === 'Ready') {
|
|
||||||
try {
|
|
||||||
const assigned = await this.missedQueue.assignNext(this.defaultAgentId);
|
|
||||||
if (assigned) {
|
|
||||||
return { status: 'ok', message: `State changed to Ready. Assigned missed call ${assigned.id}`, assignedCall: assigned };
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(`Auto-assignment on Ready failed: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('agent-ready')
|
// force-ready moved to /api/maint/force-ready
|
||||||
async agentReady() {
|
|
||||||
this.logger.log(`Force ready: logging out and back in agent ${this.defaultAgentId}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.ozonetelAgent.logoutAgent({
|
|
||||||
agentId: this.defaultAgentId,
|
|
||||||
password: this.defaultAgentPassword,
|
|
||||||
});
|
|
||||||
const result = await this.ozonetelAgent.loginAgent({
|
|
||||||
agentId: this.defaultAgentId,
|
|
||||||
password: this.defaultAgentPassword,
|
|
||||||
phoneNumber: this.defaultSipId,
|
|
||||||
mode: 'blended',
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
} catch (error: any) {
|
|
||||||
const message = error.response?.data?.message ?? error.message ?? 'Force ready failed';
|
|
||||||
this.logger.error(`Force ready failed: ${message}`);
|
|
||||||
throw new HttpException(message, error.response?.status ?? 502);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('dispose')
|
@Post('dispose')
|
||||||
async dispose(
|
async dispose(
|
||||||
@@ -132,19 +122,21 @@ export class OzonetelAgentController {
|
|||||||
throw new HttpException('ucid and disposition required', 400);
|
throw new HttpException('ucid and disposition required', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`Dispose: ucid=${body.ucid} disposition=${body.disposition}`);
|
|
||||||
|
|
||||||
const ozonetelDisposition = this.mapToOzonetelDisposition(body.disposition);
|
const ozonetelDisposition = this.mapToOzonetelDisposition(body.disposition);
|
||||||
|
|
||||||
|
this.logger.log(`[DISPOSE] ucid=${body.ucid} disposition=${body.disposition} → ozonetel="${ozonetelDisposition}" agentId=${this.defaultAgentId} callerPhone=${body.callerPhone ?? 'none'} direction=${body.direction ?? 'unknown'} leadId=${body.leadId ?? 'none'}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.ozonetelAgent.setDisposition({
|
const result = await this.ozonetelAgent.setDisposition({
|
||||||
agentId: this.defaultAgentId,
|
agentId: this.defaultAgentId,
|
||||||
ucid: body.ucid,
|
ucid: body.ucid,
|
||||||
disposition: ozonetelDisposition,
|
disposition: ozonetelDisposition,
|
||||||
});
|
});
|
||||||
|
this.logger.log(`[DISPOSE] Ozonetel response: ${JSON.stringify(result)}`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const message = error.response?.data?.message ?? error.message ?? 'Disposition failed';
|
const message = error.response?.data?.message ?? error.message ?? 'Disposition failed';
|
||||||
this.logger.error(`Dispose failed: ${message}`);
|
const responseData = error.response?.data ? JSON.stringify(error.response.data) : '';
|
||||||
|
this.logger.error(`[DISPOSE] FAILED: ${message} ${responseData}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle missed call callback status update
|
// Handle missed call callback status update
|
||||||
@@ -175,6 +167,20 @@ export class OzonetelAgentController {
|
|||||||
this.logger.warn(`Auto-assignment after dispose failed: ${err}`);
|
this.logger.warn(`Auto-assignment after dispose failed: ${err}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit event for downstream processing (AI insights, metrics, etc.)
|
||||||
|
this.eventBus.emit(Topics.CALL_COMPLETED, {
|
||||||
|
callId: null,
|
||||||
|
ucid: body.ucid,
|
||||||
|
agentId: this.defaultAgentId,
|
||||||
|
callerPhone: body.callerPhone ?? '',
|
||||||
|
direction: body.direction ?? 'INBOUND',
|
||||||
|
durationSec: body.durationSec ?? 0,
|
||||||
|
disposition: body.disposition,
|
||||||
|
leadId: body.leadId ?? null,
|
||||||
|
notes: body.notes ?? null,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
return { status: 'ok' };
|
return { status: 'ok' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,9 +192,9 @@ export class OzonetelAgentController {
|
|||||||
throw new HttpException('phoneNumber required', 400);
|
throw new HttpException('phoneNumber required', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const campaignName = body.campaignName ?? process.env.OZONETEL_CAMPAIGN_NAME ?? 'Inbound_918041763265';
|
const campaignName = body.campaignName ?? this.telephony.getConfig().ozonetel.campaignName ?? 'Inbound_918041763265';
|
||||||
|
|
||||||
this.logger.log(`Manual dial: ${body.phoneNumber} campaign=${campaignName} (lead: ${body.leadId ?? 'none'})`);
|
this.logger.log(`[DIAL] phone=${body.phoneNumber} campaign=${campaignName} agentId=${this.defaultAgentId} lead=${body.leadId ?? 'none'}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.ozonetelAgent.manualDial({
|
const result = await this.ozonetelAgent.manualDial({
|
||||||
|
|||||||
269
src/ozonetel/ozonetel-agent.service.spec.ts
Normal file
269
src/ozonetel/ozonetel-agent.service.spec.ts
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
/**
|
||||||
|
* Ozonetel Agent Service — unit tests
|
||||||
|
*
|
||||||
|
* QA coverage: agent auth (login/logout), manual dial, set disposition,
|
||||||
|
* change agent state, call control. Covers the Ozonetel HTTP layer that
|
||||||
|
* backs TC-IB-01→06, TC-OB-01→06, TC-FU-01→02 via disposition flows.
|
||||||
|
*
|
||||||
|
* axios is mocked — no real HTTP to Ozonetel.
|
||||||
|
*/
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { OzonetelAgentService } from './ozonetel-agent.service';
|
||||||
|
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||||
|
import axios from 'axios';
|
||||||
|
import {
|
||||||
|
AGENT_AUTH_LOGIN_SUCCESS,
|
||||||
|
AGENT_AUTH_LOGIN_ALREADY,
|
||||||
|
AGENT_AUTH_LOGOUT_SUCCESS,
|
||||||
|
AGENT_AUTH_INVALID,
|
||||||
|
DISPOSITION_SET_DURING_CALL,
|
||||||
|
DISPOSITION_SET_AFTER_CALL,
|
||||||
|
DISPOSITION_INVALID_UCID,
|
||||||
|
} from '../__fixtures__/ozonetel-payloads';
|
||||||
|
|
||||||
|
jest.mock('axios');
|
||||||
|
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||||
|
|
||||||
|
describe('OzonetelAgentService', () => {
|
||||||
|
let service: OzonetelAgentService;
|
||||||
|
|
||||||
|
const mockTelephonyConfig = {
|
||||||
|
exotel: {
|
||||||
|
apiKey: 'KK_TEST_KEY',
|
||||||
|
accountSid: 'test_account',
|
||||||
|
subdomain: 'in1-ccaas-api.ozonetel.com',
|
||||||
|
},
|
||||||
|
ozonetel: {
|
||||||
|
agentId: 'global',
|
||||||
|
agentPassword: 'Test123$',
|
||||||
|
sipId: '523590',
|
||||||
|
campaignName: 'Inbound_918041763400',
|
||||||
|
did: '918041763400',
|
||||||
|
},
|
||||||
|
sip: { domain: 'blr-pub-rtc4.ozonetel.com', wsPort: '444' },
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Mock token generation (needed before most API calls)
|
||||||
|
mockedAxios.post.mockImplementation(async (url: string, data?: any) => {
|
||||||
|
if (url.includes('generateToken')) {
|
||||||
|
return { data: { token: 'mock-bearer-token', status: 'success' } };
|
||||||
|
}
|
||||||
|
return { data: {} };
|
||||||
|
});
|
||||||
|
|
||||||
|
const module = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
OzonetelAgentService,
|
||||||
|
{
|
||||||
|
provide: TelephonyConfigService,
|
||||||
|
useValue: { getConfig: () => mockTelephonyConfig },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get(OzonetelAgentService);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Agent Login ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('loginAgent', () => {
|
||||||
|
it('should send correct params to Ozonetel auth endpoint', async () => {
|
||||||
|
mockedAxios.post.mockResolvedValueOnce({ data: AGENT_AUTH_LOGIN_SUCCESS });
|
||||||
|
|
||||||
|
const result = await service.loginAgent({
|
||||||
|
agentId: 'global',
|
||||||
|
password: 'Test123$',
|
||||||
|
phoneNumber: '523590',
|
||||||
|
mode: 'blended',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual(AGENT_AUTH_LOGIN_SUCCESS);
|
||||||
|
|
||||||
|
const authCall = mockedAxios.post.mock.calls[0];
|
||||||
|
expect(authCall[0]).toContain('AgentAuthenticationV2');
|
||||||
|
// Body is URLSearchParams string
|
||||||
|
expect(authCall[1]).toContain('userName=test_account');
|
||||||
|
expect(authCall[1]).toContain('apiKey=KK_TEST_KEY');
|
||||||
|
expect(authCall[1]).toContain('phoneNumber=523590');
|
||||||
|
expect(authCall[1]).toContain('action=login');
|
||||||
|
expect(authCall[1]).toContain('mode=blended');
|
||||||
|
// Basic auth
|
||||||
|
expect(authCall[2]?.auth).toEqual({ username: 'global', password: 'Test123$' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should auto-retry on "already logged in" response', async () => {
|
||||||
|
// First call: already logged in
|
||||||
|
mockedAxios.post
|
||||||
|
.mockResolvedValueOnce({ data: AGENT_AUTH_LOGIN_ALREADY })
|
||||||
|
// Logout call
|
||||||
|
.mockResolvedValueOnce({ data: AGENT_AUTH_LOGOUT_SUCCESS })
|
||||||
|
// Re-login call
|
||||||
|
.mockResolvedValueOnce({ data: AGENT_AUTH_LOGIN_SUCCESS });
|
||||||
|
|
||||||
|
const result = await service.loginAgent({
|
||||||
|
agentId: 'global',
|
||||||
|
password: 'Test123$',
|
||||||
|
phoneNumber: '523590',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should have made 3 calls: login, logout, re-login
|
||||||
|
expect(mockedAxios.post).toHaveBeenCalledTimes(3);
|
||||||
|
expect(result).toEqual(AGENT_AUTH_LOGIN_SUCCESS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on invalid authentication', async () => {
|
||||||
|
mockedAxios.post.mockRejectedValueOnce({
|
||||||
|
response: { status: 401, data: AGENT_AUTH_INVALID },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.loginAgent({ agentId: 'bad', password: 'wrong', phoneNumber: '000' }),
|
||||||
|
).rejects.toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Agent Logout ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('logoutAgent', () => {
|
||||||
|
it('should send logout action', async () => {
|
||||||
|
mockedAxios.post.mockResolvedValueOnce({ data: AGENT_AUTH_LOGOUT_SUCCESS });
|
||||||
|
|
||||||
|
const result = await service.logoutAgent({ agentId: 'global', password: 'Test123$' });
|
||||||
|
|
||||||
|
expect(result).toEqual(AGENT_AUTH_LOGOUT_SUCCESS);
|
||||||
|
const call = mockedAxios.post.mock.calls[0];
|
||||||
|
expect(call[1]).toContain('action=logout');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Manual Dial ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('manualDial', () => {
|
||||||
|
it('should send correct params with bearer token', async () => {
|
||||||
|
// First call: token generation, second: manual dial
|
||||||
|
mockedAxios.post
|
||||||
|
.mockResolvedValueOnce({ data: { token: 'mock-token', status: 'success' } })
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: { status: 'success', ucid: '31712345678901234', message: 'Call initiated' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.manualDial({
|
||||||
|
agentId: 'global',
|
||||||
|
campaignName: 'Inbound_918041763400',
|
||||||
|
customerNumber: '9949879837',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual(expect.objectContaining({ status: 'success' }));
|
||||||
|
|
||||||
|
// The dial call (second post)
|
||||||
|
const dialCall = mockedAxios.post.mock.calls[1];
|
||||||
|
expect(dialCall[0]).toContain('AgentManualDial');
|
||||||
|
expect(dialCall[1]).toMatchObject({
|
||||||
|
userName: 'test_account',
|
||||||
|
agentID: 'global',
|
||||||
|
campaignName: 'Inbound_918041763400',
|
||||||
|
customerNumber: '9949879837',
|
||||||
|
});
|
||||||
|
expect(dialCall[2]?.headers?.Authorization).toBe('Bearer mock-token');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Set Disposition ──────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('setDisposition', () => {
|
||||||
|
it('should send disposition with correct fields', async () => {
|
||||||
|
mockedAxios.post
|
||||||
|
.mockResolvedValueOnce({ data: { token: 'mock-token', status: 'success' } })
|
||||||
|
.mockResolvedValueOnce({ data: DISPOSITION_SET_AFTER_CALL });
|
||||||
|
|
||||||
|
const result = await service.setDisposition({
|
||||||
|
agentId: 'global',
|
||||||
|
ucid: '31712345678901234',
|
||||||
|
disposition: 'General Enquiry',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual(DISPOSITION_SET_AFTER_CALL);
|
||||||
|
|
||||||
|
const dispCall = mockedAxios.post.mock.calls[1];
|
||||||
|
expect(dispCall[0]).toContain('DispositionAPIV2');
|
||||||
|
expect(dispCall[1]).toMatchObject({
|
||||||
|
userName: 'test_account',
|
||||||
|
agentID: 'global',
|
||||||
|
ucid: '31712345678901234',
|
||||||
|
action: 'Set',
|
||||||
|
disposition: 'General Enquiry',
|
||||||
|
did: '918041763400',
|
||||||
|
autoRelease: 'true',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle queued disposition (during call)', async () => {
|
||||||
|
mockedAxios.post
|
||||||
|
.mockResolvedValueOnce({ data: { token: 'mock-token', status: 'success' } })
|
||||||
|
.mockResolvedValueOnce({ data: DISPOSITION_SET_DURING_CALL });
|
||||||
|
|
||||||
|
const result = await service.setDisposition({
|
||||||
|
agentId: 'global',
|
||||||
|
ucid: '31712345678901234',
|
||||||
|
disposition: 'Appointment Booked',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe('Success');
|
||||||
|
expect(result.message).toContain('Queued');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Change Agent State ───────────────────────────────────────
|
||||||
|
|
||||||
|
describe('changeAgentState', () => {
|
||||||
|
it('should send Ready state', async () => {
|
||||||
|
mockedAxios.post
|
||||||
|
.mockResolvedValueOnce({ data: { token: 'mock-token', status: 'success' } })
|
||||||
|
.mockResolvedValueOnce({ data: { status: 'success', message: 'State changed' } });
|
||||||
|
|
||||||
|
await service.changeAgentState({ agentId: 'global', state: 'Ready' });
|
||||||
|
|
||||||
|
const stateCall = mockedAxios.post.mock.calls[1];
|
||||||
|
expect(stateCall[0]).toContain('changeAgentState');
|
||||||
|
expect(stateCall[1]).toMatchObject({
|
||||||
|
userName: 'test_account',
|
||||||
|
agentId: 'global',
|
||||||
|
state: 'Ready',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include pauseReason when pausing', async () => {
|
||||||
|
mockedAxios.post
|
||||||
|
.mockResolvedValueOnce({ data: { token: 'mock-token', status: 'success' } })
|
||||||
|
.mockResolvedValueOnce({ data: { status: 'success', message: 'Agent paused' } });
|
||||||
|
|
||||||
|
await service.changeAgentState({ agentId: 'global', state: 'Pause', pauseReason: 'Break' });
|
||||||
|
|
||||||
|
const stateCall = mockedAxios.post.mock.calls[1];
|
||||||
|
expect(stateCall[1]).toMatchObject({ pauseReason: 'Break' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Token caching ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('token management', () => {
|
||||||
|
it('should cache token and reuse for subsequent calls', async () => {
|
||||||
|
// First call generates token
|
||||||
|
mockedAxios.post
|
||||||
|
.mockResolvedValueOnce({ data: { token: 'cached-token', status: 'success' } })
|
||||||
|
.mockResolvedValueOnce({ data: { status: 'success' } })
|
||||||
|
// Second API call should reuse token
|
||||||
|
.mockResolvedValueOnce({ data: { status: 'success' } });
|
||||||
|
|
||||||
|
await service.manualDial({ agentId: 'a', campaignName: 'c', customerNumber: '1' });
|
||||||
|
await service.manualDial({ agentId: 'a', campaignName: 'c', customerNumber: '2' });
|
||||||
|
|
||||||
|
// Token generation should only be called once
|
||||||
|
const tokenCalls = mockedAxios.post.mock.calls.filter(c => c[0].includes('generateToken'));
|
||||||
|
expect(tokenCalls.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,20 +1,27 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OzonetelAgentService {
|
export class OzonetelAgentService {
|
||||||
private readonly logger = new Logger(OzonetelAgentService.name);
|
private readonly logger = new Logger(OzonetelAgentService.name);
|
||||||
private readonly apiDomain: string;
|
|
||||||
private readonly apiKey: string;
|
|
||||||
private readonly accountId: string;
|
|
||||||
private cachedToken: string | null = null;
|
private cachedToken: string | null = null;
|
||||||
private tokenExpiry: number = 0;
|
private tokenExpiry: number = 0;
|
||||||
|
|
||||||
constructor(private config: ConfigService) {
|
constructor(private telephony: TelephonyConfigService) {}
|
||||||
this.apiDomain = config.get<string>('exotel.subdomain') ?? 'in1-ccaas-api.ozonetel.com';
|
|
||||||
this.apiKey = config.get<string>('exotel.apiKey') ?? '';
|
// Read-through getters so admin updates to telephony.json take effect
|
||||||
this.accountId = config.get<string>('exotel.accountSid') ?? '';
|
// immediately without a sidecar restart. The default for apiDomain is
|
||||||
|
// preserved here because the legacy env-var path used a different default
|
||||||
|
// ('in1-ccaas-api.ozonetel.com') than the rest of the Exotel config.
|
||||||
|
private get apiDomain(): string {
|
||||||
|
return this.telephony.getConfig().exotel.subdomain || 'in1-ccaas-api.ozonetel.com';
|
||||||
|
}
|
||||||
|
private get apiKey(): string {
|
||||||
|
return this.telephony.getConfig().exotel.apiKey;
|
||||||
|
}
|
||||||
|
private get accountId(): string {
|
||||||
|
return this.telephony.getConfig().exotel.accountSid;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getToken(): Promise<string> {
|
private async getToken(): Promise<string> {
|
||||||
@@ -22,6 +29,10 @@ export class OzonetelAgentService {
|
|||||||
return this.cachedToken;
|
return this.cachedToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return this.refreshToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshToken(): Promise<string> {
|
||||||
const url = `https://${this.apiDomain}/ca_apis/CAToken/generateToken`;
|
const url = `https://${this.apiDomain}/ca_apis/CAToken/generateToken`;
|
||||||
this.logger.log('Generating CloudAgent API token');
|
this.logger.log('Generating CloudAgent API token');
|
||||||
|
|
||||||
@@ -32,7 +43,7 @@ export class OzonetelAgentService {
|
|||||||
const data = response.data;
|
const data = response.data;
|
||||||
if (data.token) {
|
if (data.token) {
|
||||||
this.cachedToken = data.token;
|
this.cachedToken = data.token;
|
||||||
this.tokenExpiry = Date.now() + 55 * 60 * 1000;
|
this.tokenExpiry = Date.now() + 10 * 60 * 1000; // 10 min cache (Ozonetel expires in ~15 min)
|
||||||
this.logger.log('CloudAgent token generated successfully');
|
this.logger.log('CloudAgent token generated successfully');
|
||||||
return data.token;
|
return data.token;
|
||||||
}
|
}
|
||||||
@@ -40,6 +51,12 @@ export class OzonetelAgentService {
|
|||||||
throw new Error(data.message ?? 'Token generation failed');
|
throw new Error(data.message ?? 'Token generation failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private invalidateToken(): void {
|
||||||
|
this.cachedToken = null;
|
||||||
|
this.tokenExpiry = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async loginAgent(params: {
|
async loginAgent(params: {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -74,15 +91,38 @@ export class OzonetelAgentService {
|
|||||||
|
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
|
|
||||||
// "already logged in" is not a real error — treat as success
|
// "already logged in" — force logout + re-login to refresh SIP phone mapping
|
||||||
if (data.status === 'error' && data.message?.includes('already logged in')) {
|
if (data.status === 'error' && data.message?.includes('already logged in')) {
|
||||||
this.logger.log(`Agent ${params.agentId} already logged in — treating as success`);
|
this.logger.log(`Agent ${params.agentId} already logged in — forcing logout + re-login`);
|
||||||
return { status: 'success', message: data.message };
|
try {
|
||||||
|
await this.logoutAgent({ agentId: params.agentId, password: params.password });
|
||||||
|
const retryResponse = await axios.post(
|
||||||
|
url,
|
||||||
|
new URLSearchParams({
|
||||||
|
userName: this.accountId,
|
||||||
|
apiKey: this.apiKey,
|
||||||
|
phoneNumber: params.phoneNumber,
|
||||||
|
action: 'login',
|
||||||
|
mode: params.mode ?? 'blended',
|
||||||
|
state: 'Ready',
|
||||||
|
}).toString(),
|
||||||
|
{
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
auth: { username: params.agentId, password: params.password },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.logger.log(`Agent re-login response: ${JSON.stringify(retryResponse.data)}`);
|
||||||
|
return retryResponse.data;
|
||||||
|
} catch (retryErr: any) {
|
||||||
|
this.logger.error(`Agent re-login failed: ${retryErr.message}`);
|
||||||
|
return { status: 'success', message: 'Re-login attempted' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`Agent login response: ${JSON.stringify(data)}`);
|
this.logger.log(`Agent login response: ${JSON.stringify(data)}`);
|
||||||
return data;
|
return data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (error?.response?.status === 401) this.invalidateToken();
|
||||||
this.logger.error(`Agent login failed: ${error.message}`);
|
this.logger.error(`Agent login failed: ${error.message}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -111,10 +151,10 @@ export class OzonetelAgentService {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`Manual dial response: ${JSON.stringify(response.data)}`);
|
this.logger.log(`Manual dial response: ${JSON.stringify(response.data)}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (error?.response?.status === 401) this.invalidateToken();
|
||||||
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
|
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
|
||||||
this.logger.error(`Manual dial failed: ${error.message} ${responseData}`);
|
this.logger.error(`Manual dial failed: ${error.message} ${responseData}`);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -163,7 +203,7 @@ export class OzonetelAgentService {
|
|||||||
disposition: string;
|
disposition: string;
|
||||||
}): Promise<{ status: string; message?: string; details?: string }> {
|
}): Promise<{ status: string; message?: string; details?: string }> {
|
||||||
const url = `https://${this.apiDomain}/ca_apis/DispositionAPIV2`;
|
const url = `https://${this.apiDomain}/ca_apis/DispositionAPIV2`;
|
||||||
const did = process.env.OZONETEL_DID ?? '918041763265';
|
const did = this.telephony.getConfig().ozonetel.did;
|
||||||
|
|
||||||
this.logger.log(`Set disposition: agent=${params.agentId} ucid=${params.ucid} disposition=${params.disposition}`);
|
this.logger.log(`Set disposition: agent=${params.agentId} ucid=${params.ucid} disposition=${params.disposition}`);
|
||||||
|
|
||||||
@@ -199,8 +239,9 @@ export class OzonetelAgentService {
|
|||||||
conferenceNumber?: string;
|
conferenceNumber?: string;
|
||||||
}): Promise<{ status: string; message: string; ucid?: string }> {
|
}): Promise<{ status: string; message: string; ucid?: string }> {
|
||||||
const url = `https://${this.apiDomain}/ca_apis/CallControl_V4`;
|
const url = `https://${this.apiDomain}/ca_apis/CallControl_V4`;
|
||||||
const did = process.env.OZONETEL_DID ?? '918041763265';
|
const tcfg = this.telephony.getConfig().ozonetel;
|
||||||
const agentPhoneName = process.env.OZONETEL_SIP_ID ?? '523590';
|
const did = tcfg.did;
|
||||||
|
const agentPhoneName = tcfg.sipId;
|
||||||
|
|
||||||
this.logger.log(`Call control: action=${params.action} ucid=${params.ucid} conference=${params.conferenceNumber ?? 'none'}`);
|
this.logger.log(`Call control: action=${params.action} ucid=${params.ucid} conference=${params.conferenceNumber ?? 'none'}`);
|
||||||
|
|
||||||
@@ -304,6 +345,7 @@ export class OzonetelAgentService {
|
|||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (error?.response?.status === 401) this.invalidateToken();
|
||||||
this.logger.error(`Abandon calls failed: ${error.message}`);
|
this.logger.error(`Abandon calls failed: ${error.message}`);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -393,6 +435,7 @@ export class OzonetelAgentService {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (error?.response?.status === 401) this.invalidateToken();
|
||||||
this.logger.error(`Agent summary failed: ${error.message}`);
|
this.logger.error(`Agent summary failed: ${error.message}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -422,6 +465,7 @@ export class OzonetelAgentService {
|
|||||||
}
|
}
|
||||||
return '00:00:00';
|
return '00:00:00';
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (error?.response?.status === 401) this.invalidateToken();
|
||||||
this.logger.error(`AHT failed: ${error.message}`);
|
this.logger.error(`AHT failed: ${error.message}`);
|
||||||
return '00:00:00';
|
return '00:00:00';
|
||||||
}
|
}
|
||||||
@@ -459,6 +503,7 @@ export class OzonetelAgentService {
|
|||||||
this.logger.log(`Agent logout response: ${JSON.stringify(response.data)}`);
|
this.logger.log(`Agent logout response: ${JSON.stringify(response.data)}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (error?.response?.status === 401) this.invalidateToken();
|
||||||
this.logger.error(`Agent logout failed: ${error.message}`);
|
this.logger.error(`Agent logout failed: ${error.message}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,10 +180,16 @@ export class PlatformGraphqlService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateLeadWithToken(id: string, input: UpdateLeadInput, authHeader: string): Promise<LeadNode> {
|
async updateLeadWithToken(id: string, input: UpdateLeadInput, authHeader: string): Promise<LeadNode> {
|
||||||
|
// Response fragment deliberately excludes `leadStatus` — the staging
|
||||||
|
// platform schema has this field renamed to `status`. Selecting the
|
||||||
|
// old name rejects the whole mutation. Callers don't use the
|
||||||
|
// returned fragment today, so returning just the id + AI fields
|
||||||
|
// keeps this working across both schema shapes without a wider
|
||||||
|
// rename hotfix.
|
||||||
const data = await this.queryWithAuth<{ updateLead: LeadNode }>(
|
const data = await this.queryWithAuth<{ updateLead: LeadNode }>(
|
||||||
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
|
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
|
||||||
updateLead(id: $id, data: $data) {
|
updateLead(id: $id, data: $data) {
|
||||||
id leadStatus aiSummary aiSuggestedAction
|
id aiSummary aiSuggestedAction
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
{ id, data: input },
|
{ id, data: input },
|
||||||
@@ -192,6 +198,67 @@ export class PlatformGraphqlService {
|
|||||||
return data.updateLead;
|
return data.updateLead;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch a single lead by id with the caller's JWT. Used by the
|
||||||
|
// lead-enrich flow when the agent explicitly renames a caller from
|
||||||
|
// the appointment/enquiry form and we need to regenerate the lead's
|
||||||
|
// AI summary against fresh identity.
|
||||||
|
//
|
||||||
|
// The selected fields deliberately use the staging-aligned names
|
||||||
|
// (`status`, `source`, `lastContacted`) rather than the older
|
||||||
|
// `leadStatus`/`leadSource`/`lastContactedAt` names — otherwise the
|
||||||
|
// query would be rejected on staging.
|
||||||
|
async findLeadByIdWithToken(id: string, authHeader: string): Promise<any | null> {
|
||||||
|
try {
|
||||||
|
const data = await this.queryWithAuth<{ lead: any }>(
|
||||||
|
`query FindLead($id: UUID!) {
|
||||||
|
lead(filter: { id: { eq: $id } }) {
|
||||||
|
id
|
||||||
|
createdAt
|
||||||
|
contactName { firstName lastName }
|
||||||
|
contactPhone { primaryPhoneNumber primaryPhoneCallingCode }
|
||||||
|
source
|
||||||
|
status
|
||||||
|
interestedService
|
||||||
|
contactAttempts
|
||||||
|
lastContacted
|
||||||
|
aiSummary
|
||||||
|
aiSuggestedAction
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ id },
|
||||||
|
authHeader,
|
||||||
|
);
|
||||||
|
return data.lead ?? null;
|
||||||
|
} catch {
|
||||||
|
// Fall back to edge-style query in case the singular field
|
||||||
|
// doesn't exist on this platform version.
|
||||||
|
const data = await this.queryWithAuth<{ leads: { edges: { node: any }[] } }>(
|
||||||
|
`query FindLead($id: UUID!) {
|
||||||
|
leads(filter: { id: { eq: $id } }, first: 1) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
createdAt
|
||||||
|
contactName { firstName lastName }
|
||||||
|
contactPhone { primaryPhoneNumber primaryPhoneCallingCode }
|
||||||
|
source
|
||||||
|
status
|
||||||
|
interestedService
|
||||||
|
contactAttempts
|
||||||
|
lastContacted
|
||||||
|
aiSummary
|
||||||
|
aiSuggestedAction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ id },
|
||||||
|
authHeader,
|
||||||
|
);
|
||||||
|
return data.leads.edges[0]?.node ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Server-to-server versions (for webhooks, background jobs) ---
|
// --- Server-to-server versions (for webhooks, background jobs) ---
|
||||||
|
|
||||||
async getLeadActivities(leadId: string, limit = 3): Promise<LeadActivityNode[]> {
|
async getLeadActivities(leadId: string, limit = 3): Promise<LeadActivityNode[]> {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export type CreateCallInput = {
|
|||||||
disposition?: string;
|
disposition?: string;
|
||||||
callNotes?: string;
|
callNotes?: string;
|
||||||
leadId?: string;
|
leadId?: string;
|
||||||
|
sla?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreateLeadActivityInput = {
|
export type CreateLeadActivityInput = {
|
||||||
|
|||||||
53
src/recordings/recordings.controller.ts
Normal file
53
src/recordings/recordings.controller.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Controller, Post, Body, Logger, HttpException } from '@nestjs/common';
|
||||||
|
import { RecordingsService } from './recordings.service';
|
||||||
|
import { SessionService } from '../auth/session.service';
|
||||||
|
|
||||||
|
const CACHE_TTL = 7 * 24 * 3600; // 7 days
|
||||||
|
|
||||||
|
@Controller('api/recordings')
|
||||||
|
export class RecordingsController {
|
||||||
|
private readonly logger = new Logger(RecordingsController.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly recordings: RecordingsService,
|
||||||
|
private readonly session: SessionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post('analyze')
|
||||||
|
async analyze(@Body() body: { recordingUrl: string; callId?: string }) {
|
||||||
|
if (!body.recordingUrl) {
|
||||||
|
throw new HttpException('recordingUrl required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = body.callId ? `call:analysis:${body.callId}` : null;
|
||||||
|
|
||||||
|
// Check Redis cache first
|
||||||
|
if (cacheKey) {
|
||||||
|
try {
|
||||||
|
const cached = await this.session.getCache(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
this.logger.log(`[RECORDING] Cache hit: ${cacheKey}`);
|
||||||
|
return JSON.parse(cached);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[RECORDING] Cache miss — analyzing: ${body.recordingUrl} callId=${body.callId ?? 'none'}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const analysis = await this.recordings.analyzeRecording(body.recordingUrl);
|
||||||
|
this.logger.log(`[RECORDING] Analysis complete: ${analysis.transcript.length} utterances, sentiment=${analysis.sentiment}`);
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
if (cacheKey) {
|
||||||
|
this.session.setCache(cacheKey, JSON.stringify(analysis), CACHE_TTL)
|
||||||
|
.catch(err => this.logger.warn(`[RECORDING] Cache write failed: ${err}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
return analysis;
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`[RECORDING] Analysis failed: ${error.message}`);
|
||||||
|
throw new HttpException(error.message ?? 'Analysis failed', 502);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/recordings/recordings.module.ts
Normal file
11
src/recordings/recordings.module.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { RecordingsController } from './recordings.controller';
|
||||||
|
import { RecordingsService } from './recordings.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule],
|
||||||
|
controllers: [RecordingsController],
|
||||||
|
providers: [RecordingsService],
|
||||||
|
})
|
||||||
|
export class RecordingsModule {}
|
||||||
260
src/recordings/recordings.service.ts
Normal file
260
src/recordings/recordings.service.ts
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
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';
|
||||||
|
|
||||||
|
export type TranscriptWord = {
|
||||||
|
word: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
speaker: number;
|
||||||
|
confidence: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TranscriptUtterance = {
|
||||||
|
speaker: number;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CallAnalysis = {
|
||||||
|
transcript: TranscriptUtterance[];
|
||||||
|
summary: string | null;
|
||||||
|
sentiment: 'positive' | 'neutral' | 'negative' | 'mixed';
|
||||||
|
sentimentScore: number;
|
||||||
|
insights: {
|
||||||
|
keyTopics: string[];
|
||||||
|
actionItems: string[];
|
||||||
|
coachingNotes: string[];
|
||||||
|
complianceFlags: string[];
|
||||||
|
patientSatisfaction: string;
|
||||||
|
callOutcome: string;
|
||||||
|
};
|
||||||
|
durationSec: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RecordingsService {
|
||||||
|
private readonly logger = new Logger(RecordingsService.name);
|
||||||
|
private readonly deepgramApiKey: string;
|
||||||
|
private readonly aiModel: LanguageModel | null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private config: ConfigService,
|
||||||
|
private aiConfig: AiConfigService,
|
||||||
|
) {
|
||||||
|
this.deepgramApiKey = process.env.DEEPGRAM_API_KEY ?? '';
|
||||||
|
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> {
|
||||||
|
if (!this.deepgramApiKey) throw new Error('DEEPGRAM_API_KEY not configured');
|
||||||
|
|
||||||
|
this.logger.log(`[RECORDING] Analyzing: ${recordingUrl}`);
|
||||||
|
|
||||||
|
// Step 1: Send to Deepgram pre-recorded API with diarization + sentiment
|
||||||
|
const dgResponse = await fetch(DEEPGRAM_API + '?' + new URLSearchParams({
|
||||||
|
model: 'nova-2',
|
||||||
|
language: 'multi',
|
||||||
|
smart_format: 'true',
|
||||||
|
diarize: 'true',
|
||||||
|
multichannel: 'true',
|
||||||
|
topics: 'true',
|
||||||
|
sentiment: 'true',
|
||||||
|
utterances: 'true',
|
||||||
|
}), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Token ${this.deepgramApiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ url: recordingUrl }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dgResponse.ok) {
|
||||||
|
const err = await dgResponse.text();
|
||||||
|
this.logger.error(`[RECORDING] Deepgram failed: ${dgResponse.status} ${err}`);
|
||||||
|
throw new Error(`Deepgram transcription failed: ${dgResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dgData = await dgResponse.json();
|
||||||
|
const results = dgData.results;
|
||||||
|
|
||||||
|
// Extract utterances (channel-labeled for multichannel, speaker-labeled otherwise)
|
||||||
|
const utterances: TranscriptUtterance[] = (results?.utterances ?? []).map((u: any) => ({
|
||||||
|
speaker: u.channel ?? u.speaker ?? 0,
|
||||||
|
start: u.start ?? 0,
|
||||||
|
end: u.end ?? 0,
|
||||||
|
text: u.transcript ?? '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Extract summary
|
||||||
|
const summary = results?.summary?.short ?? null;
|
||||||
|
|
||||||
|
// Extract sentiment from Deepgram
|
||||||
|
const sentiments = results?.sentiments?.segments ?? [];
|
||||||
|
const avgSentiment = this.computeAverageSentiment(sentiments);
|
||||||
|
|
||||||
|
// Extract topics
|
||||||
|
const topics = results?.topics?.segments?.flatMap((s: any) =>
|
||||||
|
(s.topics ?? []).map((t: any) => t.topic),
|
||||||
|
) ?? [];
|
||||||
|
|
||||||
|
const duration = results?.channels?.[0]?.alternatives?.[0]?.words?.length > 0
|
||||||
|
? results.channels[0].alternatives[0].words.slice(-1)[0].end
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Step 2: Build raw transcript with channel labels for AI to identify roles
|
||||||
|
const rawTranscript = utterances.map(u =>
|
||||||
|
`Channel ${u.speaker}: ${u.text}`,
|
||||||
|
).join('\n');
|
||||||
|
|
||||||
|
this.logger.log(`[RECORDING] Transcribed: ${utterances.length} utterances, ${Math.round(duration)}s`);
|
||||||
|
|
||||||
|
// Step 3: Ask AI to identify agent vs customer, then generate insights
|
||||||
|
const speakerMap = await this.identifySpeakers(rawTranscript);
|
||||||
|
const fullTranscript = utterances.map(u =>
|
||||||
|
`${speakerMap[u.speaker] ?? `Speaker ${u.speaker}`}: ${u.text}`,
|
||||||
|
).join('\n');
|
||||||
|
|
||||||
|
// Remap utterance speaker labels for the frontend
|
||||||
|
for (const u of utterances) {
|
||||||
|
// 0 = agent, 1 = customer in the returned data
|
||||||
|
const role = speakerMap[u.speaker];
|
||||||
|
if (role === 'Agent') u.speaker = 0;
|
||||||
|
else if (role === 'Customer') u.speaker = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const insights = await this.generateInsights(fullTranscript, summary, topics);
|
||||||
|
|
||||||
|
return {
|
||||||
|
transcript: utterances,
|
||||||
|
summary,
|
||||||
|
sentiment: avgSentiment.label,
|
||||||
|
sentimentScore: avgSentiment.score,
|
||||||
|
insights,
|
||||||
|
durationSec: Math.round(duration),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async identifySpeakers(rawTranscript: string): Promise<Record<number, string>> {
|
||||||
|
if (!this.aiModel || !rawTranscript.trim()) {
|
||||||
|
return { 0: 'Customer', 1: 'Agent' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { object } = await generateObject({
|
||||||
|
model: this.aiModel,
|
||||||
|
schema: z.object({
|
||||||
|
agentChannel: z.number().describe('The channel number (0 or 1) that is the call center agent'),
|
||||||
|
reasoning: z.string().describe('Brief explanation of how you identified the agent'),
|
||||||
|
}),
|
||||||
|
system: `You are analyzing a hospital call center recording transcript.
|
||||||
|
Each line is labeled with a channel number. One channel is the call center agent, the other is the customer/patient.
|
||||||
|
|
||||||
|
The AGENT typically:
|
||||||
|
- Greets professionally ("Hello, Global Hospital", "How can I help you?")
|
||||||
|
- Asks for patient details (name, phone, department)
|
||||||
|
- Provides information about doctors, schedules, services
|
||||||
|
- Navigates systems, puts on hold, transfers calls
|
||||||
|
|
||||||
|
The CUSTOMER typically:
|
||||||
|
- Asks questions about appointments, doctors, services
|
||||||
|
- Provides personal details when asked
|
||||||
|
- Describes symptoms or reasons for calling`,
|
||||||
|
prompt: rawTranscript,
|
||||||
|
maxOutputTokens: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const agentCh = object.agentChannel;
|
||||||
|
const customerCh = agentCh === 0 ? 1 : 0;
|
||||||
|
this.logger.log(`[RECORDING] Speaker ID: agent=Ch${agentCh}, customer=Ch${customerCh} (${object.reasoning})`);
|
||||||
|
return { [agentCh]: 'Agent', [customerCh]: 'Customer' };
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[RECORDING] Speaker identification failed: ${err}`);
|
||||||
|
return { 0: 'Customer', 1: 'Agent' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private computeAverageSentiment(segments: any[]): { label: 'positive' | 'neutral' | 'negative' | 'mixed'; score: number } {
|
||||||
|
if (!segments?.length) return { label: 'neutral', score: 0 };
|
||||||
|
|
||||||
|
let positive = 0, negative = 0, neutral = 0;
|
||||||
|
for (const seg of segments) {
|
||||||
|
const s = seg.sentiment ?? 'neutral';
|
||||||
|
if (s === 'positive') positive++;
|
||||||
|
else if (s === 'negative') negative++;
|
||||||
|
else neutral++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = segments.length;
|
||||||
|
const score = (positive - negative) / total;
|
||||||
|
|
||||||
|
if (positive > negative * 2) return { label: 'positive', score };
|
||||||
|
if (negative > positive * 2) return { label: 'negative', score };
|
||||||
|
if (positive > 0 && negative > 0) return { label: 'mixed', score };
|
||||||
|
return { label: 'neutral', score };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateInsights(
|
||||||
|
transcript: string,
|
||||||
|
summary: string | null,
|
||||||
|
topics: string[],
|
||||||
|
): Promise<CallAnalysis['insights']> {
|
||||||
|
if (!this.aiModel || !transcript.trim()) {
|
||||||
|
return {
|
||||||
|
keyTopics: topics.slice(0, 5),
|
||||||
|
actionItems: [],
|
||||||
|
coachingNotes: [],
|
||||||
|
complianceFlags: [],
|
||||||
|
patientSatisfaction: 'Unknown',
|
||||||
|
callOutcome: 'Unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { object } = await generateObject({
|
||||||
|
model: this.aiModel,
|
||||||
|
schema: z.object({
|
||||||
|
keyTopics: z.array(z.string()).describe('Main topics discussed (max 5)'),
|
||||||
|
actionItems: z.array(z.string()).describe('Follow-up actions needed'),
|
||||||
|
coachingNotes: z.array(z.string()).describe('Agent performance observations — what went well and what could improve'),
|
||||||
|
complianceFlags: z.array(z.string()).describe('Any compliance concerns (HIPAA, patient safety, misinformation)'),
|
||||||
|
patientSatisfaction: z.string().describe('One-line assessment of patient satisfaction'),
|
||||||
|
callOutcome: z.string().describe('One-line summary of what was accomplished'),
|
||||||
|
}),
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
return object;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`[RECORDING] AI insights failed: ${err}`);
|
||||||
|
return {
|
||||||
|
keyTopics: topics.slice(0, 5),
|
||||||
|
actionItems: [],
|
||||||
|
coachingNotes: [],
|
||||||
|
complianceFlags: [],
|
||||||
|
patientSatisfaction: 'Analysis unavailable',
|
||||||
|
callOutcome: 'Analysis unavailable',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/rules-engine/actions/assign.action.ts
Normal file
12
src/rules-engine/actions/assign.action.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// src/rules-engine/actions/assign.action.ts
|
||||||
|
|
||||||
|
import type { ActionHandler, ActionResult } from '../types/action.types';
|
||||||
|
import type { RuleAction } from '../types/rule.types';
|
||||||
|
|
||||||
|
export class AssignActionHandler implements ActionHandler {
|
||||||
|
type = 'assign';
|
||||||
|
|
||||||
|
async execute(_action: RuleAction, _context: Record<string, any>): Promise<ActionResult> {
|
||||||
|
return { success: true, data: { stub: true, action: 'assign' } };
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/rules-engine/actions/escalate.action.ts
Normal file
12
src/rules-engine/actions/escalate.action.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// src/rules-engine/actions/escalate.action.ts
|
||||||
|
|
||||||
|
import type { ActionHandler, ActionResult } from '../types/action.types';
|
||||||
|
import type { RuleAction } from '../types/rule.types';
|
||||||
|
|
||||||
|
export class EscalateActionHandler implements ActionHandler {
|
||||||
|
type = 'escalate';
|
||||||
|
|
||||||
|
async execute(_action: RuleAction, _context: Record<string, any>): Promise<ActionResult> {
|
||||||
|
return { success: true, data: { stub: true, action: 'escalate' } };
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/rules-engine/actions/score.action.ts
Normal file
33
src/rules-engine/actions/score.action.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// src/rules-engine/actions/score.action.ts
|
||||||
|
|
||||||
|
import type { ActionHandler, ActionResult } from '../types/action.types';
|
||||||
|
import type { RuleAction, ScoreActionParams } from '../types/rule.types';
|
||||||
|
import { computeSlaMultiplier } from '../facts/call-facts.provider';
|
||||||
|
|
||||||
|
export class ScoreActionHandler implements ActionHandler {
|
||||||
|
type = 'score';
|
||||||
|
|
||||||
|
async execute(action: RuleAction, context: Record<string, any>): Promise<ActionResult> {
|
||||||
|
const params = action.params as ScoreActionParams;
|
||||||
|
let score = params.weight;
|
||||||
|
let slaApplied = false;
|
||||||
|
let campaignApplied = false;
|
||||||
|
|
||||||
|
if (params.slaMultiplier && context['call.slaElapsedPercent'] != null) {
|
||||||
|
score *= computeSlaMultiplier(context['call.slaElapsedPercent']);
|
||||||
|
slaApplied = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.campaignMultiplier) {
|
||||||
|
const campaignWeight = (context['_campaignWeight'] ?? 5) / 10;
|
||||||
|
const sourceWeight = (context['_sourceWeight'] ?? 5) / 10;
|
||||||
|
score *= campaignWeight * sourceWeight;
|
||||||
|
campaignApplied = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { score, weight: params.weight, slaApplied, campaignApplied },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/rules-engine/consumers/worklist.consumer.ts
Normal file
25
src/rules-engine/consumers/worklist.consumer.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// src/rules-engine/consumers/worklist.consumer.ts
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { RulesEngineService } from '../rules-engine.service';
|
||||||
|
import { RulesStorageService } from '../rules-storage.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WorklistConsumer {
|
||||||
|
private readonly logger = new Logger(WorklistConsumer.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly engine: RulesEngineService,
|
||||||
|
private readonly storage: RulesStorageService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async scoreAndRank(worklistItems: any[]): Promise<any[]> {
|
||||||
|
const rules = await this.storage.getByTrigger('on_request', 'worklist');
|
||||||
|
if (rules.length === 0) {
|
||||||
|
this.logger.debug('No scoring rules configured — returning unsorted');
|
||||||
|
return worklistItems;
|
||||||
|
}
|
||||||
|
this.logger.debug(`Scoring ${worklistItems.length} items with ${rules.length} rules`);
|
||||||
|
return this.engine.scoreWorklist(worklistItems);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/rules-engine/facts/agent-facts.provider.ts
Normal file
18
src/rules-engine/facts/agent-facts.provider.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// src/rules-engine/facts/agent-facts.provider.ts
|
||||||
|
|
||||||
|
import type { FactProvider, FactValue } from '../types/fact.types';
|
||||||
|
|
||||||
|
export class AgentFactsProvider implements FactProvider {
|
||||||
|
name = 'agent';
|
||||||
|
|
||||||
|
async resolveFacts(agent: any): Promise<Record<string, FactValue>> {
|
||||||
|
return {
|
||||||
|
'agent.status': agent.status ?? 'OFFLINE',
|
||||||
|
'agent.activeCallCount': agent.activeCallCount ?? 0,
|
||||||
|
'agent.todayCallCount': agent.todayCallCount ?? 0,
|
||||||
|
'agent.skills': agent.skills ?? [],
|
||||||
|
'agent.campaigns': agent.campaigns ?? [],
|
||||||
|
'agent.idleMinutes': agent.idleMinutes ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/rules-engine/facts/call-facts.provider.ts
Normal file
52
src/rules-engine/facts/call-facts.provider.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// src/rules-engine/facts/call-facts.provider.ts
|
||||||
|
|
||||||
|
import type { FactProvider, FactValue } from '../types/fact.types';
|
||||||
|
import type { PriorityConfig } from '../types/rule.types';
|
||||||
|
|
||||||
|
export class CallFactsProvider implements FactProvider {
|
||||||
|
name = 'call';
|
||||||
|
|
||||||
|
async resolveFacts(call: any, priorityConfig?: PriorityConfig): Promise<Record<string, FactValue>> {
|
||||||
|
const taskType = this.inferTaskType(call);
|
||||||
|
const slaMinutes = priorityConfig?.taskWeights[taskType]?.slaMinutes ?? 1440;
|
||||||
|
const createdAt = call.createdAt ? new Date(call.createdAt).getTime() : Date.now();
|
||||||
|
const elapsedMinutes = Math.round((Date.now() - createdAt) / 60000);
|
||||||
|
const slaElapsedPercent = Math.round((elapsedMinutes / slaMinutes) * 100);
|
||||||
|
|
||||||
|
return {
|
||||||
|
'call.direction': call.callDirection ?? call.direction ?? null,
|
||||||
|
'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.slaElapsedPercent': slaElapsedPercent,
|
||||||
|
'call.slaBreached': slaElapsedPercent > 100,
|
||||||
|
'call.missedCount': call.missedcallcount ?? call.missedCount ?? 0,
|
||||||
|
'call.taskType': taskType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private inferTaskType(call: any): string {
|
||||||
|
if (call.callStatus === 'MISSED' || call.type === 'missed') return 'missed_call';
|
||||||
|
if (call.followUpType === 'CALLBACK' || call.type === 'callback') return 'follow_up';
|
||||||
|
if (call.type === 'follow-up') return 'follow_up';
|
||||||
|
if (call.contactAttempts >= 3) return 'attempt_3';
|
||||||
|
if (call.contactAttempts >= 2) return 'attempt_2';
|
||||||
|
if (call.campaignId || call.type === 'lead') return 'campaign_lead';
|
||||||
|
return 'campaign_lead';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exported scoring functions — used by both sidecar and frontend (via scoring.ts)
|
||||||
|
export function computeSlaMultiplier(slaElapsedPercent: number): number {
|
||||||
|
const elapsed = slaElapsedPercent / 100;
|
||||||
|
if (elapsed > 1) return 1.0 + (elapsed - 1) * 0.5;
|
||||||
|
return Math.pow(elapsed, 1.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeSlaStatus(slaElapsedPercent: number): 'low' | 'medium' | 'high' | 'critical' {
|
||||||
|
if (slaElapsedPercent > 100) return 'critical';
|
||||||
|
if (slaElapsedPercent >= 80) return 'high';
|
||||||
|
if (slaElapsedPercent >= 50) return 'medium';
|
||||||
|
return 'low';
|
||||||
|
}
|
||||||
30
src/rules-engine/facts/lead-facts.provider.ts
Normal file
30
src/rules-engine/facts/lead-facts.provider.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// src/rules-engine/facts/lead-facts.provider.ts
|
||||||
|
|
||||||
|
import type { FactProvider, FactValue } from '../types/fact.types';
|
||||||
|
|
||||||
|
export class LeadFactsProvider implements FactProvider {
|
||||||
|
name = 'lead';
|
||||||
|
|
||||||
|
async resolveFacts(lead: any): Promise<Record<string, FactValue>> {
|
||||||
|
const createdAt = lead.createdAt ? new Date(lead.createdAt).getTime() : Date.now();
|
||||||
|
const lastContacted = lead.lastContacted ? new Date(lead.lastContacted).getTime() : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
'lead.source': lead.leadSource ?? lead.source ?? null,
|
||||||
|
'lead.status': lead.leadStatus ?? lead.status ?? null,
|
||||||
|
'lead.priority': lead.priority ?? 'NORMAL',
|
||||||
|
'lead.campaignId': lead.campaignId ?? null,
|
||||||
|
'lead.campaignName': lead.campaignName ?? null,
|
||||||
|
'lead.interestedService': lead.interestedService ?? null,
|
||||||
|
'lead.contactAttempts': lead.contactAttempts ?? 0,
|
||||||
|
'lead.ageMinutes': Math.round((Date.now() - createdAt) / 60000),
|
||||||
|
'lead.ageDays': Math.round((Date.now() - createdAt) / 86400000),
|
||||||
|
'lead.lastContactedMinutes': lastContacted ? Math.round((Date.now() - lastContacted) / 60000) : null,
|
||||||
|
'lead.hasPatient': !!lead.patientId,
|
||||||
|
'lead.isDuplicate': lead.isDuplicate ?? false,
|
||||||
|
'lead.isSpam': lead.isSpam ?? false,
|
||||||
|
'lead.spamScore': lead.spamScore ?? 0,
|
||||||
|
'lead.leadScore': lead.leadScore ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
123
src/rules-engine/rules-engine.controller.ts
Normal file
123
src/rules-engine/rules-engine.controller.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
// src/rules-engine/rules-engine.controller.ts
|
||||||
|
|
||||||
|
import { Controller, Get, Post, Put, Delete, Patch, Param, Body, HttpException, Logger } from '@nestjs/common';
|
||||||
|
import { RulesStorageService } from './rules-storage.service';
|
||||||
|
import { RulesEngineService } from './rules-engine.service';
|
||||||
|
import type { Rule, PriorityConfig } from './types/rule.types';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
@Controller('api/rules')
|
||||||
|
export class RulesEngineController {
|
||||||
|
private readonly logger = new Logger(RulesEngineController.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly storage: RulesStorageService,
|
||||||
|
private readonly engine: RulesEngineService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// --- Priority Config (slider UI) ---
|
||||||
|
|
||||||
|
@Get('priority-config')
|
||||||
|
async getPriorityConfig() {
|
||||||
|
return this.storage.getPriorityConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('priority-config')
|
||||||
|
async updatePriorityConfig(@Body() body: PriorityConfig) {
|
||||||
|
return this.storage.updatePriorityConfig(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Rule CRUD ---
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async listRules() {
|
||||||
|
return this.storage.getAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
async getRule(@Param('id') id: string) {
|
||||||
|
const rule = await this.storage.getById(id);
|
||||||
|
if (!rule) throw new HttpException('Rule not found', 404);
|
||||||
|
return rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async createRule(@Body() body: any) {
|
||||||
|
if (!body.name || !body.trigger || !body.conditions || !body.action) {
|
||||||
|
throw new HttpException('name, trigger, conditions, and action are required', 400);
|
||||||
|
}
|
||||||
|
return this.storage.create({
|
||||||
|
...body,
|
||||||
|
ruleType: body.ruleType ?? 'priority',
|
||||||
|
enabled: body.enabled ?? true,
|
||||||
|
priority: body.priority ?? 99,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
async updateRule(@Param('id') id: string, @Body() body: Partial<Rule>) {
|
||||||
|
const updated = await this.storage.update(id, body);
|
||||||
|
if (!updated) throw new HttpException('Rule not found', 404);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
async deleteRule(@Param('id') id: string) {
|
||||||
|
const deleted = await this.storage.delete(id);
|
||||||
|
if (!deleted) throw new HttpException('Rule not found', 404);
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/toggle')
|
||||||
|
async toggleRule(@Param('id') id: string) {
|
||||||
|
const toggled = await this.storage.toggle(id);
|
||||||
|
if (!toggled) throw new HttpException('Rule not found', 404);
|
||||||
|
return toggled;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('reorder')
|
||||||
|
async reorderRules(@Body() body: { ids: string[] }) {
|
||||||
|
if (!body.ids?.length) throw new HttpException('ids array required', 400);
|
||||||
|
return this.storage.reorder(body.ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Evaluation ---
|
||||||
|
|
||||||
|
@Post('evaluate')
|
||||||
|
async evaluate(@Body() body: { trigger: string; triggerValue: string; facts: Record<string, any> }) {
|
||||||
|
return this.engine.evaluate(body.trigger, body.triggerValue, body.facts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Templates ---
|
||||||
|
|
||||||
|
@Get('templates/list')
|
||||||
|
async listTemplates() {
|
||||||
|
return [{ id: 'hospital-starter', name: 'Hospital Starter Pack', description: 'Default rules for a hospital call center', ruleCount: 7 }];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('templates/:id/apply')
|
||||||
|
async applyTemplate(@Param('id') id: string) {
|
||||||
|
if (id !== 'hospital-starter') throw new HttpException('Template not found', 404);
|
||||||
|
|
||||||
|
let template: any;
|
||||||
|
try {
|
||||||
|
template = JSON.parse(readFileSync(join(__dirname, 'templates', 'hospital-starter.json'), 'utf8'));
|
||||||
|
} catch {
|
||||||
|
throw new HttpException('Failed to load template', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply priority config
|
||||||
|
await this.storage.updatePriorityConfig(template.priorityConfig);
|
||||||
|
|
||||||
|
// Create rules
|
||||||
|
const created: Rule[] = [];
|
||||||
|
for (const rule of template.rules) {
|
||||||
|
const newRule = await this.storage.create(rule);
|
||||||
|
created.push(newRule);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Applied hospital-starter template: ${created.length} rules + priority config`);
|
||||||
|
return { status: 'ok', rulesCreated: created.length, rules: created };
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/rules-engine/rules-engine.module.ts
Normal file
14
src/rules-engine/rules-engine.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// src/rules-engine/rules-engine.module.ts
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { RulesEngineController } from './rules-engine.controller';
|
||||||
|
import { RulesEngineService } from './rules-engine.service';
|
||||||
|
import { RulesStorageService } from './rules-storage.service';
|
||||||
|
import { WorklistConsumer } from './consumers/worklist.consumer';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [RulesEngineController],
|
||||||
|
providers: [RulesEngineService, RulesStorageService, WorklistConsumer],
|
||||||
|
exports: [RulesEngineService, RulesStorageService, WorklistConsumer],
|
||||||
|
})
|
||||||
|
export class RulesEngineModule {}
|
||||||
139
src/rules-engine/rules-engine.service.ts
Normal file
139
src/rules-engine/rules-engine.service.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
// src/rules-engine/rules-engine.service.ts
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Engine } from 'json-rules-engine';
|
||||||
|
import { RulesStorageService } from './rules-storage.service';
|
||||||
|
import { LeadFactsProvider } from './facts/lead-facts.provider';
|
||||||
|
import { CallFactsProvider, computeSlaMultiplier, computeSlaStatus } from './facts/call-facts.provider';
|
||||||
|
import { AgentFactsProvider } from './facts/agent-facts.provider';
|
||||||
|
import { ScoreActionHandler } from './actions/score.action';
|
||||||
|
import { AssignActionHandler } from './actions/assign.action';
|
||||||
|
import { EscalateActionHandler } from './actions/escalate.action';
|
||||||
|
import type { Rule, ScoredItem, ScoreBreakdown, PriorityConfig } from './types/rule.types';
|
||||||
|
import type { ActionHandler } from './types/action.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RulesEngineService {
|
||||||
|
private readonly logger = new Logger(RulesEngineService.name);
|
||||||
|
private readonly leadFacts = new LeadFactsProvider();
|
||||||
|
private readonly callFacts = new CallFactsProvider();
|
||||||
|
private readonly agentFacts = new AgentFactsProvider();
|
||||||
|
private readonly actionHandlers: Map<string, ActionHandler>;
|
||||||
|
|
||||||
|
constructor(private readonly storage: RulesStorageService) {
|
||||||
|
this.actionHandlers = new Map([
|
||||||
|
['score', new ScoreActionHandler()],
|
||||||
|
['assign', new AssignActionHandler()],
|
||||||
|
['escalate', new EscalateActionHandler()],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async evaluate(triggerType: string, triggerValue: string, factContext: Record<string, any>): Promise<{ rulesApplied: string[]; results: any[] }> {
|
||||||
|
const rules = await this.storage.getByTrigger(triggerType, triggerValue);
|
||||||
|
if (rules.length === 0) return { rulesApplied: [], results: [] };
|
||||||
|
|
||||||
|
const engine = new Engine();
|
||||||
|
const ruleMap = new Map<string, Rule>();
|
||||||
|
|
||||||
|
for (const rule of rules) {
|
||||||
|
engine.addRule({
|
||||||
|
conditions: rule.conditions as any,
|
||||||
|
event: { type: rule.action.type, params: { ruleId: rule.id, ...rule.action.params as any } },
|
||||||
|
priority: rule.priority,
|
||||||
|
});
|
||||||
|
ruleMap.set(rule.id, rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(factContext)) {
|
||||||
|
engine.addFact(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { events } = await engine.run();
|
||||||
|
const results: any[] = [];
|
||||||
|
const rulesApplied: string[] = [];
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
const ruleId = event.params?.ruleId;
|
||||||
|
const rule = ruleMap.get(ruleId);
|
||||||
|
if (!rule) continue;
|
||||||
|
const handler = this.actionHandlers.get(event.type);
|
||||||
|
if (handler) {
|
||||||
|
const result = await handler.execute(rule.action, factContext);
|
||||||
|
results.push({ ruleId, ruleName: rule.name, ...result });
|
||||||
|
rulesApplied.push(rule.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rulesApplied, results };
|
||||||
|
}
|
||||||
|
|
||||||
|
async scoreWorklistItem(item: any, priorityConfig: PriorityConfig): Promise<ScoredItem> {
|
||||||
|
const leadFacts = await this.leadFacts.resolveFacts(item.originalLead ?? item);
|
||||||
|
const callFacts = await this.callFacts.resolveFacts(item, priorityConfig);
|
||||||
|
const taskType = callFacts['call.taskType'] as string;
|
||||||
|
|
||||||
|
// Inject priority config weights into context for the score action
|
||||||
|
const campaignWeight = item.campaignId ? (priorityConfig.campaignWeights[item.campaignId] ?? 5) : 5;
|
||||||
|
const sourceWeight = priorityConfig.sourceWeights[leadFacts['lead.source'] as string] ?? 5;
|
||||||
|
|
||||||
|
const allFacts: Record<string, any> = {
|
||||||
|
...leadFacts,
|
||||||
|
...callFacts,
|
||||||
|
'_campaignWeight': campaignWeight,
|
||||||
|
'_sourceWeight': sourceWeight,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { rulesApplied, results } = await this.evaluate('on_request', 'worklist', allFacts);
|
||||||
|
|
||||||
|
let totalScore = 0;
|
||||||
|
let slaMultiplierVal = 1;
|
||||||
|
let campaignMultiplierVal = 1;
|
||||||
|
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.success && result.data?.score != null) {
|
||||||
|
totalScore += result.data.score;
|
||||||
|
if (result.data.slaApplied) slaMultiplierVal = computeSlaMultiplier((allFacts['call.slaElapsedPercent'] as number) ?? 0);
|
||||||
|
if (result.data.campaignApplied) campaignMultiplierVal = (campaignWeight / 10) * (sourceWeight / 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const slaElapsedPercent = (allFacts['call.slaElapsedPercent'] as number) ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
score: Math.round(totalScore * 100) / 100,
|
||||||
|
scoreBreakdown: {
|
||||||
|
baseScore: totalScore,
|
||||||
|
slaMultiplier: Math.round(slaMultiplierVal * 100) / 100,
|
||||||
|
campaignMultiplier: Math.round(campaignMultiplierVal * 100) / 100,
|
||||||
|
rulesApplied,
|
||||||
|
},
|
||||||
|
slaStatus: computeSlaStatus(slaElapsedPercent),
|
||||||
|
slaElapsedPercent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async scoreWorklist(items: any[]): Promise<(any & ScoredItem)[]> {
|
||||||
|
const priorityConfig = await this.storage.getPriorityConfig();
|
||||||
|
const scored = await Promise.all(
|
||||||
|
items.map(async (item) => {
|
||||||
|
const scoreData = await this.scoreWorklistItem(item, priorityConfig);
|
||||||
|
return { ...item, ...scoreData };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
scored.sort((a, b) => b.score - a.score);
|
||||||
|
return scored;
|
||||||
|
}
|
||||||
|
|
||||||
|
async previewScoring(items: any[], config: PriorityConfig): Promise<(any & ScoredItem)[]> {
|
||||||
|
// Same as scoreWorklist but uses provided config (for live preview)
|
||||||
|
const scored = await Promise.all(
|
||||||
|
items.map(async (item) => {
|
||||||
|
const scoreData = await this.scoreWorklistItem(item, config);
|
||||||
|
return { ...item, ...scoreData };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
scored.sort((a, b) => b.score - a.score);
|
||||||
|
return scored;
|
||||||
|
}
|
||||||
|
}
|
||||||
186
src/rules-engine/rules-storage.service.ts
Normal file
186
src/rules-engine/rules-storage.service.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
// src/rules-engine/rules-storage.service.ts
|
||||||
|
|
||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import type { Rule, PriorityConfig } from './types/rule.types';
|
||||||
|
import { DEFAULT_PRIORITY_CONFIG } from './types/rule.types';
|
||||||
|
|
||||||
|
const RULES_KEY = 'rules:config';
|
||||||
|
const PRIORITY_CONFIG_KEY = 'rules:priority-config';
|
||||||
|
const VERSION_KEY = 'rules:scores:version';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RulesStorageService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(RulesStorageService.name);
|
||||||
|
private readonly redis: Redis;
|
||||||
|
private readonly backupDir: string;
|
||||||
|
|
||||||
|
constructor(private config: ConfigService) {
|
||||||
|
this.redis = new Redis(config.get<string>('REDIS_URL') ?? 'redis://localhost:6379');
|
||||||
|
this.backupDir = config.get<string>('RULES_BACKUP_DIR') ?? join(process.cwd(), 'data');
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
// Restore rules from backup if Redis is empty
|
||||||
|
const existing = await this.redis.get(RULES_KEY);
|
||||||
|
if (!existing) {
|
||||||
|
const rulesBackup = join(this.backupDir, 'rules-config.json');
|
||||||
|
if (existsSync(rulesBackup)) {
|
||||||
|
const data = readFileSync(rulesBackup, 'utf8');
|
||||||
|
await this.redis.set(RULES_KEY, data);
|
||||||
|
this.logger.log(`Restored ${JSON.parse(data).length} rules from backup`);
|
||||||
|
} else {
|
||||||
|
await this.redis.set(RULES_KEY, '[]');
|
||||||
|
this.logger.log('Initialized empty rules config');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore priority config from backup if Redis is empty
|
||||||
|
const existingConfig = await this.redis.get(PRIORITY_CONFIG_KEY);
|
||||||
|
if (!existingConfig) {
|
||||||
|
const configBackup = join(this.backupDir, 'priority-config.json');
|
||||||
|
if (existsSync(configBackup)) {
|
||||||
|
const data = readFileSync(configBackup, 'utf8');
|
||||||
|
await this.redis.set(PRIORITY_CONFIG_KEY, data);
|
||||||
|
this.logger.log('Restored priority config from backup');
|
||||||
|
} else {
|
||||||
|
await this.redis.set(PRIORITY_CONFIG_KEY, JSON.stringify(DEFAULT_PRIORITY_CONFIG));
|
||||||
|
this.logger.log('Initialized default priority config');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Priority Config ---
|
||||||
|
|
||||||
|
async getPriorityConfig(): Promise<PriorityConfig> {
|
||||||
|
const data = await this.redis.get(PRIORITY_CONFIG_KEY);
|
||||||
|
return data ? JSON.parse(data) : DEFAULT_PRIORITY_CONFIG;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePriorityConfig(config: PriorityConfig): Promise<PriorityConfig> {
|
||||||
|
await this.redis.set(PRIORITY_CONFIG_KEY, JSON.stringify(config));
|
||||||
|
await this.redis.incr(VERSION_KEY);
|
||||||
|
this.backupFile('priority-config.json', config);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Rules CRUD ---
|
||||||
|
|
||||||
|
async getAll(): Promise<Rule[]> {
|
||||||
|
const data = await this.redis.get(RULES_KEY);
|
||||||
|
return data ? JSON.parse(data) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getById(id: string): Promise<Rule | null> {
|
||||||
|
const rules = await this.getAll();
|
||||||
|
return rules.find(r => r.id === id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByTrigger(triggerType: string, triggerValue?: string): Promise<Rule[]> {
|
||||||
|
const rules = await this.getAll();
|
||||||
|
return rules.filter(r => {
|
||||||
|
if (!r.enabled) return false;
|
||||||
|
if (r.trigger.type !== triggerType) return false;
|
||||||
|
if (triggerValue && 'request' in r.trigger && r.trigger.request !== triggerValue) return false;
|
||||||
|
if (triggerValue && 'event' in r.trigger && r.trigger.event !== triggerValue) return false;
|
||||||
|
return true;
|
||||||
|
}).sort((a, b) => a.priority - b.priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(rule: Omit<Rule, 'id' | 'metadata'> & { createdBy?: string }): Promise<Rule> {
|
||||||
|
const rules = await this.getAll();
|
||||||
|
const newRule: Rule = {
|
||||||
|
...rule,
|
||||||
|
id: randomUUID(),
|
||||||
|
metadata: {
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
createdBy: rule.createdBy ?? 'system',
|
||||||
|
category: this.inferCategory(rule.action.type),
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
rules.push(newRule);
|
||||||
|
await this.saveRules(rules);
|
||||||
|
return newRule;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, updates: Partial<Rule>): Promise<Rule | null> {
|
||||||
|
const rules = await this.getAll();
|
||||||
|
const index = rules.findIndex(r => r.id === id);
|
||||||
|
if (index === -1) return null;
|
||||||
|
rules[index] = {
|
||||||
|
...rules[index],
|
||||||
|
...updates,
|
||||||
|
id,
|
||||||
|
metadata: { ...rules[index].metadata, updatedAt: new Date().toISOString(), ...(updates.metadata ?? {}) },
|
||||||
|
};
|
||||||
|
await this.saveRules(rules);
|
||||||
|
return rules[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<boolean> {
|
||||||
|
const rules = await this.getAll();
|
||||||
|
const filtered = rules.filter(r => r.id !== id);
|
||||||
|
if (filtered.length === rules.length) return false;
|
||||||
|
await this.saveRules(filtered);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggle(id: string): Promise<Rule | null> {
|
||||||
|
const rule = await this.getById(id);
|
||||||
|
if (!rule) return null;
|
||||||
|
return this.update(id, { enabled: !rule.enabled });
|
||||||
|
}
|
||||||
|
|
||||||
|
async reorder(ids: string[]): Promise<Rule[]> {
|
||||||
|
const rules = await this.getAll();
|
||||||
|
const reorderedIds = new Set(ids);
|
||||||
|
const reordered = ids.map((id, i) => {
|
||||||
|
const rule = rules.find(r => r.id === id);
|
||||||
|
if (rule) rule.priority = i;
|
||||||
|
return rule;
|
||||||
|
}).filter(Boolean) as Rule[];
|
||||||
|
const remaining = rules.filter(r => !reorderedIds.has(r.id));
|
||||||
|
const final = [...reordered, ...remaining];
|
||||||
|
await this.saveRules(final);
|
||||||
|
return final;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVersion(): Promise<number> {
|
||||||
|
const v = await this.redis.get(VERSION_KEY);
|
||||||
|
return v ? parseInt(v, 10) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Internal ---
|
||||||
|
|
||||||
|
private async saveRules(rules: Rule[]) {
|
||||||
|
const json = JSON.stringify(rules, null, 2);
|
||||||
|
await this.redis.set(RULES_KEY, json);
|
||||||
|
await this.redis.incr(VERSION_KEY);
|
||||||
|
this.backupFile('rules-config.json', rules);
|
||||||
|
}
|
||||||
|
|
||||||
|
private backupFile(filename: string, data: any) {
|
||||||
|
try {
|
||||||
|
if (!existsSync(this.backupDir)) mkdirSync(this.backupDir, { recursive: true });
|
||||||
|
writeFileSync(join(this.backupDir, filename), JSON.stringify(data, null, 2), 'utf8');
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to write backup ${filename}: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inferCategory(actionType: string): Rule['metadata']['category'] {
|
||||||
|
switch (actionType) {
|
||||||
|
case 'score': return 'priority';
|
||||||
|
case 'assign': return 'assignment';
|
||||||
|
case 'escalate': return 'escalation';
|
||||||
|
case 'update': return 'lifecycle';
|
||||||
|
default: return 'priority';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/rules-engine/templates/hospital-starter.json
Normal file
89
src/rules-engine/templates/hospital-starter.json
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"priorityConfig": {
|
||||||
|
"taskWeights": {
|
||||||
|
"missed_call": { "weight": 9, "slaMinutes": 720, "enabled": true },
|
||||||
|
"follow_up": { "weight": 8, "slaMinutes": 1440, "enabled": true },
|
||||||
|
"campaign_lead": { "weight": 7, "slaMinutes": 2880, "enabled": true },
|
||||||
|
"attempt_2": { "weight": 6, "slaMinutes": 1440, "enabled": true },
|
||||||
|
"attempt_3": { "weight": 4, "slaMinutes": 2880, "enabled": true }
|
||||||
|
},
|
||||||
|
"campaignWeights": {},
|
||||||
|
"sourceWeights": {
|
||||||
|
"WHATSAPP": 9, "PHONE": 8, "FACEBOOK_AD": 7, "GOOGLE_AD": 7,
|
||||||
|
"INSTAGRAM": 5, "WEBSITE": 7, "REFERRAL": 6, "WALK_IN": 5, "OTHER": 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"ruleType": "priority",
|
||||||
|
"name": "Missed calls — high urgency",
|
||||||
|
"description": "Missed calls get highest priority with SLA-based urgency",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 1,
|
||||||
|
"trigger": { "type": "on_request", "request": "worklist" },
|
||||||
|
"conditions": { "all": [{ "fact": "call.taskType", "operator": "equal", "value": "missed_call" }] },
|
||||||
|
"action": { "type": "score", "params": { "weight": 9, "slaMultiplier": true } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ruleType": "priority",
|
||||||
|
"name": "Scheduled follow-ups",
|
||||||
|
"description": "Committed callbacks from prior calls",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 2,
|
||||||
|
"trigger": { "type": "on_request", "request": "worklist" },
|
||||||
|
"conditions": { "all": [{ "fact": "call.taskType", "operator": "equal", "value": "follow_up" }] },
|
||||||
|
"action": { "type": "score", "params": { "weight": 8, "slaMultiplier": true } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ruleType": "priority",
|
||||||
|
"name": "Campaign leads — weighted",
|
||||||
|
"description": "Outbound campaign calls, weighted by campaign importance",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 3,
|
||||||
|
"trigger": { "type": "on_request", "request": "worklist" },
|
||||||
|
"conditions": { "all": [{ "fact": "call.taskType", "operator": "equal", "value": "campaign_lead" }] },
|
||||||
|
"action": { "type": "score", "params": { "weight": 7, "slaMultiplier": true, "campaignMultiplier": true } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ruleType": "priority",
|
||||||
|
"name": "2nd attempt — medium urgency",
|
||||||
|
"description": "First call went unanswered, try again",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 4,
|
||||||
|
"trigger": { "type": "on_request", "request": "worklist" },
|
||||||
|
"conditions": { "all": [{ "fact": "call.taskType", "operator": "equal", "value": "attempt_2" }] },
|
||||||
|
"action": { "type": "score", "params": { "weight": 6, "slaMultiplier": true } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ruleType": "priority",
|
||||||
|
"name": "3rd attempt — lower urgency",
|
||||||
|
"description": "Two prior unanswered attempts",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 5,
|
||||||
|
"trigger": { "type": "on_request", "request": "worklist" },
|
||||||
|
"conditions": { "all": [{ "fact": "call.taskType", "operator": "equal", "value": "attempt_3" }] },
|
||||||
|
"action": { "type": "score", "params": { "weight": 4, "slaMultiplier": true } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ruleType": "priority",
|
||||||
|
"name": "Spam leads — deprioritize",
|
||||||
|
"description": "High spam score leads get pushed down",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 10,
|
||||||
|
"trigger": { "type": "on_request", "request": "worklist" },
|
||||||
|
"conditions": { "all": [{ "fact": "lead.spamScore", "operator": "greaterThan", "value": 60 }] },
|
||||||
|
"action": { "type": "score", "params": { "weight": -3 } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ruleType": "automation",
|
||||||
|
"name": "SLA breach — escalate to supervisor",
|
||||||
|
"description": "Alert supervisor when callback SLA is breached",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 1,
|
||||||
|
"status": "draft",
|
||||||
|
"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" } }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
14
src/rules-engine/types/action.types.ts
Normal file
14
src/rules-engine/types/action.types.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// src/rules-engine/types/action.types.ts
|
||||||
|
|
||||||
|
import type { RuleAction } from './rule.types';
|
||||||
|
|
||||||
|
export interface ActionHandler {
|
||||||
|
type: string;
|
||||||
|
execute(action: RuleAction, context: Record<string, any>): Promise<ActionResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ActionResult = {
|
||||||
|
success: boolean;
|
||||||
|
data?: Record<string, any>;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
15
src/rules-engine/types/fact.types.ts
Normal file
15
src/rules-engine/types/fact.types.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// src/rules-engine/types/fact.types.ts
|
||||||
|
|
||||||
|
export type FactValue = string | number | boolean | string[] | null;
|
||||||
|
|
||||||
|
export type FactContext = {
|
||||||
|
lead?: Record<string, FactValue>;
|
||||||
|
call?: Record<string, FactValue>;
|
||||||
|
agent?: Record<string, FactValue>;
|
||||||
|
campaign?: Record<string, FactValue>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface FactProvider {
|
||||||
|
name: string;
|
||||||
|
resolveFacts(entityData: any): Promise<Record<string, FactValue>>;
|
||||||
|
}
|
||||||
126
src/rules-engine/types/rule.types.ts
Normal file
126
src/rules-engine/types/rule.types.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
// src/rules-engine/types/rule.types.ts
|
||||||
|
|
||||||
|
export type RuleType = 'priority' | 'automation';
|
||||||
|
|
||||||
|
export type RuleTrigger =
|
||||||
|
| { type: 'on_request'; request: 'worklist' | 'assignment' }
|
||||||
|
| { type: 'on_event'; event: string }
|
||||||
|
| { type: 'on_schedule'; interval: string }
|
||||||
|
| { type: 'always' };
|
||||||
|
|
||||||
|
export type RuleCategory = 'priority' | 'assignment' | 'escalation' | 'lifecycle' | 'qualification';
|
||||||
|
|
||||||
|
export type RuleOperator =
|
||||||
|
| 'equal' | 'notEqual'
|
||||||
|
| 'greaterThan' | 'greaterThanInclusive'
|
||||||
|
| 'lessThan' | 'lessThanInclusive'
|
||||||
|
| 'in' | 'notIn'
|
||||||
|
| 'contains' | 'doesNotContain'
|
||||||
|
| 'exists' | 'doesNotExist';
|
||||||
|
|
||||||
|
export type RuleCondition = {
|
||||||
|
fact: string;
|
||||||
|
operator: RuleOperator;
|
||||||
|
value: any;
|
||||||
|
path?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuleConditionGroup = {
|
||||||
|
all?: (RuleCondition | RuleConditionGroup)[];
|
||||||
|
any?: (RuleCondition | RuleConditionGroup)[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuleActionType = 'score' | 'assign' | 'escalate' | 'update' | 'notify';
|
||||||
|
|
||||||
|
export type ScoreActionParams = {
|
||||||
|
weight: number;
|
||||||
|
slaMultiplier?: boolean;
|
||||||
|
campaignMultiplier?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssignActionParams = {
|
||||||
|
agentId?: string;
|
||||||
|
agentPool?: string[];
|
||||||
|
strategy: 'specific' | 'round-robin' | 'least-loaded' | 'skill-based';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EscalateActionParams = {
|
||||||
|
channel: 'toast' | 'notification' | 'sms' | 'email';
|
||||||
|
recipients: 'supervisor' | 'agent' | string[];
|
||||||
|
message: string;
|
||||||
|
severity: 'warning' | 'critical';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateActionParams = {
|
||||||
|
entity: string;
|
||||||
|
field: string;
|
||||||
|
value: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuleAction = {
|
||||||
|
type: RuleActionType;
|
||||||
|
params: ScoreActionParams | AssignActionParams | EscalateActionParams | UpdateActionParams;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Rule = {
|
||||||
|
id: string;
|
||||||
|
ruleType: RuleType;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
priority: number;
|
||||||
|
trigger: RuleTrigger;
|
||||||
|
conditions: RuleConditionGroup;
|
||||||
|
action: RuleAction;
|
||||||
|
status?: 'draft' | 'published';
|
||||||
|
metadata: {
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
createdBy: string;
|
||||||
|
category: RuleCategory;
|
||||||
|
tags?: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ScoreBreakdown = {
|
||||||
|
baseScore: number;
|
||||||
|
slaMultiplier: number;
|
||||||
|
campaignMultiplier: number;
|
||||||
|
rulesApplied: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ScoredItem = {
|
||||||
|
id: string;
|
||||||
|
score: number;
|
||||||
|
scoreBreakdown: ScoreBreakdown;
|
||||||
|
slaStatus: 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
slaElapsedPercent: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Priority config — what the supervisor edits via sliders
|
||||||
|
export type TaskWeightConfig = {
|
||||||
|
weight: number; // 0-10
|
||||||
|
slaMinutes: number; // SLA in minutes
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PriorityConfig = {
|
||||||
|
taskWeights: Record<string, TaskWeightConfig>;
|
||||||
|
campaignWeights: Record<string, number>; // campaignId → 0-10
|
||||||
|
sourceWeights: Record<string, number>; // leadSource → 0-10
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_PRIORITY_CONFIG: PriorityConfig = {
|
||||||
|
taskWeights: {
|
||||||
|
missed_call: { weight: 9, slaMinutes: 720, enabled: true },
|
||||||
|
follow_up: { weight: 8, slaMinutes: 1440, enabled: true },
|
||||||
|
campaign_lead: { weight: 7, slaMinutes: 2880, enabled: true },
|
||||||
|
attempt_2: { weight: 6, slaMinutes: 1440, enabled: true },
|
||||||
|
attempt_3: { weight: 4, slaMinutes: 2880, enabled: true },
|
||||||
|
},
|
||||||
|
campaignWeights: {},
|
||||||
|
sourceWeights: {
|
||||||
|
WHATSAPP: 9, PHONE: 8, FACEBOOK_AD: 7, GOOGLE_AD: 7,
|
||||||
|
INSTAGRAM: 5, WEBSITE: 7, REFERRAL: 6, WALK_IN: 5, OTHER: 5,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -50,7 +50,7 @@ export class SearchController {
|
|||||||
|
|
||||||
this.platform.queryWithAuth<any>(
|
this.platform.queryWithAuth<any>(
|
||||||
`{ appointments(first: 50, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
`{ appointments(first: 50, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||||
id scheduledAt doctorName department appointmentStatus patientId
|
id scheduledAt doctorName department status patientId
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, authHeader,
|
undefined, authHeader,
|
||||||
).catch(() => ({ appointments: { edges: [] } })),
|
).catch(() => ({ appointments: { edges: [] } })),
|
||||||
|
|||||||
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));
|
||||||
|
};
|
||||||
55
src/supervisor/supervisor.controller.ts
Normal file
55
src/supervisor/supervisor.controller.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { Controller, Get, Post, Body, Query, Sse, Logger } from '@nestjs/common';
|
||||||
|
import { Observable, filter, map } from 'rxjs';
|
||||||
|
import { SupervisorService } from './supervisor.service';
|
||||||
|
|
||||||
|
@Controller('api/supervisor')
|
||||||
|
export class SupervisorController {
|
||||||
|
private readonly logger = new Logger(SupervisorController.name);
|
||||||
|
|
||||||
|
constructor(private readonly supervisor: SupervisorService) {}
|
||||||
|
|
||||||
|
@Get('active-calls')
|
||||||
|
getActiveCalls() {
|
||||||
|
return this.supervisor.getActiveCalls();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('team-performance')
|
||||||
|
async getTeamPerformance(@Query('date') date?: string) {
|
||||||
|
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
||||||
|
this.logger.log(`Team performance: date=${targetDate}`);
|
||||||
|
return this.supervisor.getTeamPerformance(targetDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('call-event')
|
||||||
|
handleCallEvent(@Body() body: any) {
|
||||||
|
const event = body.data ?? body;
|
||||||
|
this.logger.log(`[CALL-EVENT] ${JSON.stringify(event)}`);
|
||||||
|
this.supervisor.handleCallEvent(event);
|
||||||
|
return { received: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('agent-event')
|
||||||
|
handleAgentEvent(@Body() body: any) {
|
||||||
|
const event = body.data ?? body;
|
||||||
|
this.logger.log(`[AGENT-EVENT] ${JSON.stringify(event)}`);
|
||||||
|
this.supervisor.handleAgentEvent(event);
|
||||||
|
return { received: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('agent-state')
|
||||||
|
getAgentState(@Query('agentId') agentId: string) {
|
||||||
|
const state = this.supervisor.getAgentState(agentId);
|
||||||
|
return state ?? { state: 'offline', timestamp: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Sse('agent-state/stream')
|
||||||
|
streamAgentState(@Query('agentId') agentId: string): Observable<MessageEvent> {
|
||||||
|
this.logger.log(`[SSE] Agent state stream opened for ${agentId}`);
|
||||||
|
return this.supervisor.agentStateSubject.pipe(
|
||||||
|
filter(event => event.agentId === agentId),
|
||||||
|
map(event => ({
|
||||||
|
data: JSON.stringify({ state: event.state, timestamp: event.timestamp }),
|
||||||
|
} as MessageEvent)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/supervisor/supervisor.module.ts
Normal file
13
src/supervisor/supervisor.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
||||||
|
import { SupervisorController } from './supervisor.controller';
|
||||||
|
import { SupervisorService } from './supervisor.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PlatformModule, OzonetelAgentModule],
|
||||||
|
controllers: [SupervisorController],
|
||||||
|
providers: [SupervisorService],
|
||||||
|
exports: [SupervisorService],
|
||||||
|
})
|
||||||
|
export class SupervisorModule {}
|
||||||
139
src/supervisor/supervisor.service.ts
Normal file
139
src/supervisor/supervisor.service.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
||||||
|
|
||||||
|
type ActiveCall = {
|
||||||
|
ucid: string;
|
||||||
|
agentId: string;
|
||||||
|
callerNumber: string;
|
||||||
|
callType: string;
|
||||||
|
startTime: string;
|
||||||
|
status: 'active' | 'on-hold';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentOzonetelState = 'ready' | 'break' | 'training' | 'calling' | 'in-call' | 'acw' | 'offline';
|
||||||
|
|
||||||
|
type AgentStateEntry = {
|
||||||
|
state: AgentOzonetelState;
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@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 }>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private platform: PlatformGraphqlService,
|
||||||
|
private ozonetel: OzonetelAgentService,
|
||||||
|
private config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
this.logger.log('Supervisor service initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCallEvent(event: any) {
|
||||||
|
const action = event.action;
|
||||||
|
const ucid = event.ucid ?? event.monitorUCID;
|
||||||
|
const agentId = event.agent_id ?? event.agentID;
|
||||||
|
const callerNumber = event.caller_id ?? event.callerID;
|
||||||
|
const callType = event.call_type ?? event.Type;
|
||||||
|
const eventTime = event.event_time ?? event.eventTime ?? new Date().toISOString();
|
||||||
|
|
||||||
|
if (!ucid) return;
|
||||||
|
|
||||||
|
if (action === 'Answered' || action === 'Calling') {
|
||||||
|
this.activeCalls.set(ucid, {
|
||||||
|
ucid, agentId, callerNumber,
|
||||||
|
callType, startTime: eventTime, status: 'active',
|
||||||
|
});
|
||||||
|
this.logger.log(`Active call: ${agentId} ↔ ${callerNumber} (${ucid})`);
|
||||||
|
} else if (action === 'Disconnect') {
|
||||||
|
this.activeCalls.delete(ucid);
|
||||||
|
this.logger.log(`Call ended: ${ucid}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAgentEvent(event: any) {
|
||||||
|
const agentId = event.agentId ?? event.agent_id ?? 'unknown';
|
||||||
|
const action = event.action ?? 'unknown';
|
||||||
|
const eventData = event.eventData ?? '';
|
||||||
|
const eventTime = event.event_time ?? event.eventTime ?? new Date().toISOString();
|
||||||
|
this.logger.log(`[AGENT-STATE] ${agentId} → ${action}${eventData ? ` (${eventData})` : ''} at ${eventTime}`);
|
||||||
|
|
||||||
|
const mapped = this.mapOzonetelAction(action, eventData);
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapOzonetelAction(action: string, eventData: string): AgentOzonetelState | null {
|
||||||
|
switch (action) {
|
||||||
|
case 'release': return 'ready';
|
||||||
|
case 'IDLE': return 'ready'; // agent available after unanswered/canceled call
|
||||||
|
case 'calling': return 'calling';
|
||||||
|
case 'incall': return 'in-call';
|
||||||
|
case 'ACW': return 'acw';
|
||||||
|
case 'logout': return 'offline';
|
||||||
|
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';
|
||||||
|
return 'break';
|
||||||
|
case 'login': return null; // wait for release
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getAgentState(agentId: string): AgentStateEntry | null {
|
||||||
|
return this.agentStates.get(agentId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
emitForceLogout(agentId: string) {
|
||||||
|
this.logger.log(`[AGENT-STATE] Emitting force-logout for ${agentId}`);
|
||||||
|
this.agentStates.set(agentId, { state: 'offline', timestamp: new Date().toISOString() });
|
||||||
|
// Use a special state so frontend can distinguish admin force-logout from normal Ozonetel logout
|
||||||
|
this.agentStateSubject.next({ agentId, state: 'force-logout' as any, timestamp: new Date().toISOString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveCalls(): ActiveCall[] {
|
||||||
|
return Array.from(this.activeCalls.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTeamPerformance(date: string): Promise<any> {
|
||||||
|
// 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 minConversion
|
||||||
|
} } } }`,
|
||||||
|
);
|
||||||
|
const agents = agentData?.agents?.edges?.map((e: any) => e.node) ?? [];
|
||||||
|
|
||||||
|
// Fetch Ozonetel time summary per agent
|
||||||
|
const summaries = await Promise.all(
|
||||||
|
agents.map(async (agent: any) => {
|
||||||
|
if (!agent.ozonetelAgentId) return { ...agent, timeBreakdown: null };
|
||||||
|
try {
|
||||||
|
const summary = await this.ozonetel.getAgentSummary(agent.ozonetelAgentId, date);
|
||||||
|
return { ...agent, timeBreakdown: summary };
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to get summary for ${agent.ozonetelAgentId}: ${err}`);
|
||||||
|
return { ...agent, timeBreakdown: null };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { date, agents: summaries };
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/team/team.controller.ts
Normal file
39
src/team/team.controller.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
TeamService,
|
||||||
|
type CreateMemberInput,
|
||||||
|
type CreatedMember,
|
||||||
|
type UpdateMemberInput,
|
||||||
|
} from './team.service';
|
||||||
|
|
||||||
|
// REST wrapper around TeamService. Mounted at /api/team/*.
|
||||||
|
// The Team wizard step on the frontend posts here instead of firing
|
||||||
|
// the platform's sendInvitations mutation directly.
|
||||||
|
|
||||||
|
@Controller('api/team')
|
||||||
|
export class TeamController {
|
||||||
|
constructor(private team: TeamService) {}
|
||||||
|
|
||||||
|
@Post('members')
|
||||||
|
async createMember(@Body() body: CreateMemberInput): Promise<CreatedMember> {
|
||||||
|
return this.team.createMember(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('members/:id')
|
||||||
|
async updateMember(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: UpdateMemberInput,
|
||||||
|
): Promise<{ id: string }> {
|
||||||
|
return this.team.updateMember(id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the cached plaintext temp password for a recently-created
|
||||||
|
// member if it's still within its 24h TTL, or { password: null }
|
||||||
|
// on cache miss. Used by the wizard's right-pane copy icon when
|
||||||
|
// its in-browser memory was wiped by a refresh.
|
||||||
|
@Get('members/:id/temp-password')
|
||||||
|
async getTempPassword(@Param('id') id: string): Promise<{ password: string | null }> {
|
||||||
|
const password = await this.team.getTempPassword(id);
|
||||||
|
return { password };
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/team/team.module.ts
Normal file
16
src/team/team.module.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { TeamController } from './team.controller';
|
||||||
|
import { TeamService } from './team.service';
|
||||||
|
|
||||||
|
// AuthModule is imported because TeamService uses SessionService for
|
||||||
|
// its generic Redis cache (storing recently-created temp passwords
|
||||||
|
// with a 24h TTL so the right pane's copy icon survives a reload).
|
||||||
|
@Module({
|
||||||
|
imports: [PlatformModule, AuthModule],
|
||||||
|
controllers: [TeamController],
|
||||||
|
providers: [TeamService],
|
||||||
|
exports: [TeamService],
|
||||||
|
})
|
||||||
|
export class TeamModule {}
|
||||||
334
src/team/team.service.ts
Normal file
334
src/team/team.service.ts
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { SessionService } from '../auth/session.service';
|
||||||
|
|
||||||
|
// Recently-created temp passwords are cached in Redis under this prefix
|
||||||
|
// for 24 hours so the right pane's copy icon keeps working after a
|
||||||
|
// browser refresh. The plaintext expires automatically — the assumption
|
||||||
|
// is the employee logs in within a day, at which point the password
|
||||||
|
// loses value anyway.
|
||||||
|
const TEMP_PASSWORD_KEY_PREFIX = 'team:tempPassword:';
|
||||||
|
const TEMP_PASSWORD_TTL_SECONDS = 24 * 60 * 60;
|
||||||
|
const tempPasswordKey = (memberId: string) => `${TEMP_PASSWORD_KEY_PREFIX}${memberId}`;
|
||||||
|
|
||||||
|
// In-place employee creation. The platform's sendInvitations flow is
|
||||||
|
// deliberately NOT used — hospital admins create employees from the
|
||||||
|
// portal and hand out credentials directly (see feedback-no-invites in
|
||||||
|
// memory).
|
||||||
|
//
|
||||||
|
// Chain:
|
||||||
|
// 1. Fetch workspace invite hash (workspace-level setting) so
|
||||||
|
// signUpInWorkspace accepts our call — this is the same hash the
|
||||||
|
// public invite link uses but we consume it server-side.
|
||||||
|
// 2. signUpInWorkspace(email, password, workspaceId, workspaceInviteHash)
|
||||||
|
// — creates the core.user row + the workspaceMember row. Returns
|
||||||
|
// a loginToken we throw away (admin has their own session).
|
||||||
|
// 3. Look up the workspaceMember we just created, filtering by
|
||||||
|
// userEmail (the only field we have to go on).
|
||||||
|
// 4. updateWorkspaceMember to set firstName / lastName.
|
||||||
|
// 5. updateWorkspaceMemberRole to assign the role the admin picked.
|
||||||
|
// 6. (optional) updateAgent to link the new member to a SIP seat if
|
||||||
|
// they're a CC agent.
|
||||||
|
//
|
||||||
|
// Errors from any step bubble up as a BadRequestException — the admin
|
||||||
|
// sees the real GraphQL error message, which usually tells them
|
||||||
|
// exactly what went wrong (email already exists, role not assignable,
|
||||||
|
// etc).
|
||||||
|
|
||||||
|
export type CreateMemberInput = {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
roleId: string;
|
||||||
|
// Optional SIP seat link — set when the role is HelixEngage User
|
||||||
|
// (CC agent). Ignored otherwise.
|
||||||
|
agentId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreatedMember = {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
userEmail: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
roleId: string;
|
||||||
|
agentId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update payload — name + role only. Email and password are not
|
||||||
|
// touched (they need separate flows). SIP seat reassignment goes
|
||||||
|
// through the Telephony step's updateAgent path, not here.
|
||||||
|
export type UpdateMemberInput = {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
roleId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TeamService {
|
||||||
|
private readonly logger = new Logger(TeamService.name);
|
||||||
|
// Workspace invite hash is stable for the lifetime of the workspace
|
||||||
|
// — cache it after first fetch so subsequent creates skip the
|
||||||
|
// extra round-trip.
|
||||||
|
private cachedInviteHash: { workspaceId: string; inviteHash: string } | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private platform: PlatformGraphqlService,
|
||||||
|
private session: SessionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createMember(input: CreateMemberInput): Promise<CreatedMember> {
|
||||||
|
const email = input.email.trim().toLowerCase();
|
||||||
|
const firstName = input.firstName.trim();
|
||||||
|
const lastName = input.lastName.trim();
|
||||||
|
|
||||||
|
if (!email || !firstName || !input.password || !input.roleId) {
|
||||||
|
throw new BadRequestException('email, firstName, password and roleId are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1 — fetch workspace id + invite hash
|
||||||
|
const ws = await this.getWorkspaceContext();
|
||||||
|
|
||||||
|
// Step 2 — create the user + workspace member via signUpInWorkspace
|
||||||
|
try {
|
||||||
|
await this.platform.query(
|
||||||
|
`mutation SignUpInWorkspace($email: String!, $password: String!, $workspaceId: UUID!, $workspaceInviteHash: String!) {
|
||||||
|
signUpInWorkspace(
|
||||||
|
email: $email,
|
||||||
|
password: $password,
|
||||||
|
workspaceId: $workspaceId,
|
||||||
|
workspaceInviteHash: $workspaceInviteHash,
|
||||||
|
) {
|
||||||
|
workspace { id }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
email,
|
||||||
|
password: input.password,
|
||||||
|
workspaceId: ws.workspaceId,
|
||||||
|
workspaceInviteHash: ws.inviteHash,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`signUpInWorkspace failed for ${email}: ${err}`);
|
||||||
|
throw new BadRequestException(this.extractGraphqlMessage(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3 — find the workspaceMember that just got created. We
|
||||||
|
// filter by userEmail since that's the only handle we have.
|
||||||
|
// Plural query + client-side pick so we don't rely on a
|
||||||
|
// specific filter shape.
|
||||||
|
const membersData = await this.platform.query<{
|
||||||
|
workspaceMembers: { edges: { node: { id: string; userId: string; userEmail: string } }[] };
|
||||||
|
}>(
|
||||||
|
`{ workspaceMembers { edges { node { id userId userEmail } } } }`,
|
||||||
|
);
|
||||||
|
const member = membersData.workspaceMembers.edges
|
||||||
|
.map((e) => e.node)
|
||||||
|
.find((m) => (m.userEmail ?? '').toLowerCase() === email);
|
||||||
|
if (!member) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Workspace member was created but could not be located — retry in a few seconds',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4 — set their name. Note: the platform's
|
||||||
|
// updateWorkspaceMember mutation declares its `id` arg as
|
||||||
|
// `UUID!` (not `ID!`), and GraphQL refuses to coerce between
|
||||||
|
// those scalars even though both hold the same string value.
|
||||||
|
// Same applies to updateAgent below — verified via __schema
|
||||||
|
// introspection. Pre-existing code in platform-graphql.service
|
||||||
|
// still uses `ID!` for updateLead; that's a separate latent
|
||||||
|
// bug that's untouched here so the diff stays focused on the
|
||||||
|
// team-create failure.
|
||||||
|
try {
|
||||||
|
await this.platform.query(
|
||||||
|
`mutation UpdateWorkspaceMember($id: UUID!, $data: WorkspaceMemberUpdateInput!) {
|
||||||
|
updateWorkspaceMember(id: $id, data: $data) { id }
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
id: member.id,
|
||||||
|
data: {
|
||||||
|
name: { firstName, lastName },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`updateWorkspaceMember name failed for ${member.id}: ${err}`);
|
||||||
|
// Non-fatal — the account exists, just unnamed. Surface it
|
||||||
|
// anyway so the admin can fix in settings.
|
||||||
|
throw new BadRequestException(this.extractGraphqlMessage(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5 — assign role
|
||||||
|
try {
|
||||||
|
await this.platform.query(
|
||||||
|
`mutation AssignRole($workspaceMemberId: UUID!, $roleId: UUID!) {
|
||||||
|
updateWorkspaceMemberRole(workspaceMemberId: $workspaceMemberId, roleId: $roleId) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ workspaceMemberId: member.id, roleId: input.roleId },
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`updateWorkspaceMemberRole failed for ${member.id}: ${err}`);
|
||||||
|
throw new BadRequestException(this.extractGraphqlMessage(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6 — (optional) link SIP seat
|
||||||
|
if (input.agentId) {
|
||||||
|
try {
|
||||||
|
await this.platform.query(
|
||||||
|
`mutation LinkAgent($id: UUID!, $data: AgentUpdateInput!) {
|
||||||
|
updateAgent(id: $id, data: $data) { id workspaceMemberId }
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
id: input.agentId,
|
||||||
|
data: { workspaceMemberId: member.id },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`updateAgent link failed for agent ${input.agentId}: ${err}`);
|
||||||
|
throw new BadRequestException(this.extractGraphqlMessage(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the plaintext temp password in Redis (24h TTL) so the
|
||||||
|
// wizard's right-pane copy icon keeps working after a browser
|
||||||
|
// refresh. The password is also stored hashed on the platform
|
||||||
|
// (used for actual login auth) — this Redis copy exists ONLY
|
||||||
|
// so the admin can recover the plaintext to share with the
|
||||||
|
// employee. Expires automatically; no plaintext persists past
|
||||||
|
// 24h. Trade-off accepted because the plan is to force a
|
||||||
|
// password reset on first login (defense in depth).
|
||||||
|
try {
|
||||||
|
await this.session.setCache(
|
||||||
|
tempPasswordKey(member.id),
|
||||||
|
input.password,
|
||||||
|
TEMP_PASSWORD_TTL_SECONDS,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to cache temp password for ${member.id}: ${err}`);
|
||||||
|
// Non-fatal — admin can still copy from session memory
|
||||||
|
// before page reload. We just lose the post-reload
|
||||||
|
// recovery path for this one member.
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Created member ${email} (id=${member.id}) role=${input.roleId} agent=${input.agentId ?? 'none'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: member.id,
|
||||||
|
userId: member.userId,
|
||||||
|
userEmail: email,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
roleId: input.roleId,
|
||||||
|
agentId: input.agentId ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the cached temp password for a member, if it's still
|
||||||
|
// within its 24h TTL. Returns null on miss (cache expired, member
|
||||||
|
// never created via this endpoint, or Redis unreachable). The
|
||||||
|
// wizard's copy icon falls back to this when the in-browser
|
||||||
|
// memory was wiped by a page reload.
|
||||||
|
async getTempPassword(memberId: string): Promise<string | null> {
|
||||||
|
if (!memberId) return null;
|
||||||
|
try {
|
||||||
|
return await this.session.getCache(tempPasswordKey(memberId));
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to read temp password cache for ${memberId}: ${err}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update an existing workspace member — name + role only.
|
||||||
|
// Mirrors the create path's mutation chain but skips signUp,
|
||||||
|
// member lookup, and the SIP seat link. Errors bubble up as
|
||||||
|
// BadRequestException so the admin sees the real GraphQL message.
|
||||||
|
async updateMember(memberId: string, input: UpdateMemberInput): Promise<{ id: string }> {
|
||||||
|
const firstName = input.firstName.trim();
|
||||||
|
const lastName = input.lastName.trim();
|
||||||
|
|
||||||
|
if (!memberId || !firstName || !input.roleId) {
|
||||||
|
throw new BadRequestException('memberId, firstName and roleId are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1 — set their name
|
||||||
|
try {
|
||||||
|
await this.platform.query(
|
||||||
|
`mutation UpdateWorkspaceMember($id: UUID!, $data: WorkspaceMemberUpdateInput!) {
|
||||||
|
updateWorkspaceMember(id: $id, data: $data) { id }
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
id: memberId,
|
||||||
|
data: {
|
||||||
|
name: { firstName, lastName },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`updateWorkspaceMember name failed for ${memberId}: ${err}`);
|
||||||
|
throw new BadRequestException(this.extractGraphqlMessage(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2 — assign role (idempotent — same call as the create
|
||||||
|
// path so changing role from X to X is a no-op).
|
||||||
|
try {
|
||||||
|
await this.platform.query(
|
||||||
|
`mutation AssignRole($workspaceMemberId: UUID!, $roleId: UUID!) {
|
||||||
|
updateWorkspaceMemberRole(workspaceMemberId: $workspaceMemberId, roleId: $roleId) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ workspaceMemberId: memberId, roleId: input.roleId },
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`updateWorkspaceMemberRole failed for ${memberId}: ${err}`);
|
||||||
|
throw new BadRequestException(this.extractGraphqlMessage(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Updated member ${memberId} (name="${firstName} ${lastName}", role=${input.roleId})`);
|
||||||
|
return { id: memberId };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getWorkspaceContext(): Promise<{ workspaceId: string; inviteHash: string }> {
|
||||||
|
if (this.cachedInviteHash) return this.cachedInviteHash;
|
||||||
|
const data = await this.platform.query<{
|
||||||
|
currentWorkspace: {
|
||||||
|
id: string;
|
||||||
|
inviteHash: string;
|
||||||
|
isPublicInviteLinkEnabled: boolean;
|
||||||
|
};
|
||||||
|
}>(`{ currentWorkspace { id inviteHash isPublicInviteLinkEnabled } }`);
|
||||||
|
|
||||||
|
const ws = data.currentWorkspace;
|
||||||
|
if (!ws?.id || !ws?.inviteHash) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Workspace is missing id/inviteHash — cannot create employees in-place',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!ws.isPublicInviteLinkEnabled) {
|
||||||
|
// signUpInWorkspace will reject us without this flag set.
|
||||||
|
// Surface a clear error instead of the platform's opaque
|
||||||
|
// "FORBIDDEN" response.
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Workspace public invite link is disabled — enable it in workspace settings so the server can mint user accounts directly',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.cachedInviteHash = { workspaceId: ws.id, inviteHash: ws.inviteHash };
|
||||||
|
return this.cachedInviteHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractGraphqlMessage(err: unknown): string {
|
||||||
|
const msg = (err as Error)?.message ?? 'Unknown error';
|
||||||
|
// PlatformGraphqlService wraps errors as `GraphQL error: [{...}]`.
|
||||||
|
// Pull out the first message so the admin sees something
|
||||||
|
// meaningful in the toast.
|
||||||
|
const match = msg.match(/"message":"([^"]+)"/);
|
||||||
|
return match ? match[1] : msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
243
src/team/team.spec.ts
Normal file
243
src/team/team.spec.ts
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* Team Service — unit tests
|
||||||
|
*
|
||||||
|
* Tests the in-place workspace member creation flow (no email invites):
|
||||||
|
* 1. signUpInWorkspace → creates user + workspace member
|
||||||
|
* 2. updateWorkspaceMember → set name
|
||||||
|
* 3. updateWorkspaceMemberRole → assign role
|
||||||
|
* 4. (optional) updateAgent → link SIP seat
|
||||||
|
*
|
||||||
|
* Platform GraphQL + SessionService are mocked.
|
||||||
|
*/
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { TeamService, CreateMemberInput } from './team.service';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { SessionService } from '../auth/session.service';
|
||||||
|
|
||||||
|
describe('TeamService', () => {
|
||||||
|
let service: TeamService;
|
||||||
|
let platform: jest.Mocked<PlatformGraphqlService>;
|
||||||
|
let session: jest.Mocked<SessionService>;
|
||||||
|
|
||||||
|
const mockWorkspace = {
|
||||||
|
id: '42424242-1c25-4d02-bf25-6aeccf7ea419',
|
||||||
|
inviteHash: 'test-invite-hash',
|
||||||
|
isPublicInviteLinkEnabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockMemberId = 'member-new-001';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
TeamService,
|
||||||
|
{
|
||||||
|
provide: PlatformGraphqlService,
|
||||||
|
useValue: {
|
||||||
|
query: jest.fn().mockImplementation((query: string) => {
|
||||||
|
if (query.includes('currentWorkspace')) {
|
||||||
|
return Promise.resolve({ currentWorkspace: mockWorkspace });
|
||||||
|
}
|
||||||
|
if (query.includes('signUpInWorkspace')) {
|
||||||
|
return Promise.resolve({ signUpInWorkspace: { workspace: { id: mockWorkspace.id } } });
|
||||||
|
}
|
||||||
|
if (query.includes('workspaceMembers')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
workspaceMembers: {
|
||||||
|
edges: [{
|
||||||
|
node: {
|
||||||
|
id: mockMemberId,
|
||||||
|
userId: 'user-new-001',
|
||||||
|
userEmail: 'ccagent@ramaiahcare.com',
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (query.includes('updateWorkspaceMember')) {
|
||||||
|
return Promise.resolve({ updateWorkspaceMember: { id: mockMemberId } });
|
||||||
|
}
|
||||||
|
if (query.includes('updateWorkspaceMemberRole')) {
|
||||||
|
return Promise.resolve({ updateWorkspaceMemberRole: { id: mockMemberId } });
|
||||||
|
}
|
||||||
|
if (query.includes('updateAgent')) {
|
||||||
|
return Promise.resolve({ updateAgent: { id: 'agent-001', workspaceMemberId: mockMemberId } });
|
||||||
|
}
|
||||||
|
return Promise.resolve({});
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: SessionService,
|
||||||
|
useValue: {
|
||||||
|
setCache: jest.fn().mockResolvedValue(undefined),
|
||||||
|
getCache: jest.fn().mockResolvedValue(null),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get(TeamService);
|
||||||
|
platform = module.get(PlatformGraphqlService);
|
||||||
|
session = module.get(SessionService);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Create member (standard flow) ────────────────────────────
|
||||||
|
|
||||||
|
it('should create a workspace member with correct 5-step flow', async () => {
|
||||||
|
const input: CreateMemberInput = {
|
||||||
|
firstName: 'CC',
|
||||||
|
lastName: 'Agent',
|
||||||
|
email: 'ccagent@ramaiahcare.com',
|
||||||
|
password: 'CcRamaiah@2026',
|
||||||
|
roleId: 'role-agent-001',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await service.createMember(input);
|
||||||
|
|
||||||
|
expect(result.id).toBe(mockMemberId);
|
||||||
|
expect(result.userEmail).toBe('ccagent@ramaiahcare.com');
|
||||||
|
|
||||||
|
// Verify the 5-step call chain
|
||||||
|
const calls = platform.query.mock.calls.map(c => c[0] as string);
|
||||||
|
|
||||||
|
// Step 1: fetch workspace context (inviteHash)
|
||||||
|
expect(calls.some(q => q.includes('currentWorkspace'))).toBe(true);
|
||||||
|
// Step 2: signUpInWorkspace
|
||||||
|
expect(calls.some(q => q.includes('signUpInWorkspace'))).toBe(true);
|
||||||
|
// Step 3: find member by email
|
||||||
|
expect(calls.some(q => q.includes('workspaceMembers'))).toBe(true);
|
||||||
|
// Step 4: set name
|
||||||
|
expect(calls.some(q => q.includes('updateWorkspaceMember'))).toBe(true);
|
||||||
|
// Step 5: assign role
|
||||||
|
expect(calls.some(q => q.includes('updateWorkspaceMemberRole'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Create member with SIP seat link ─────────────────────────
|
||||||
|
|
||||||
|
it('should link agent SIP seat when agentId is provided', async () => {
|
||||||
|
const input: CreateMemberInput = {
|
||||||
|
firstName: 'CC',
|
||||||
|
lastName: 'Agent',
|
||||||
|
email: 'ccagent@ramaiahcare.com',
|
||||||
|
password: 'CcRamaiah@2026',
|
||||||
|
roleId: 'role-agent-001',
|
||||||
|
agentId: 'agent-sip-001',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await service.createMember(input);
|
||||||
|
|
||||||
|
// Step 6: updateAgent (links workspaceMemberId)
|
||||||
|
const agentCall = platform.query.mock.calls.find(
|
||||||
|
c => (c[0] as string).includes('updateAgent'),
|
||||||
|
);
|
||||||
|
expect(agentCall).toBeDefined();
|
||||||
|
expect(agentCall![1]).toMatchObject({
|
||||||
|
id: 'agent-sip-001',
|
||||||
|
data: { workspaceMemberId: mockMemberId },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Validation ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('should reject missing required fields', async () => {
|
||||||
|
await expect(
|
||||||
|
service.createMember({
|
||||||
|
firstName: '',
|
||||||
|
lastName: 'Test',
|
||||||
|
email: 'test@ramaiahcare.com',
|
||||||
|
password: 'pass',
|
||||||
|
roleId: 'role-001',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject missing email', async () => {
|
||||||
|
await expect(
|
||||||
|
service.createMember({
|
||||||
|
firstName: 'Test',
|
||||||
|
lastName: 'User',
|
||||||
|
email: '',
|
||||||
|
password: 'pass',
|
||||||
|
roleId: 'role-001',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Temp password caching ────────────────────────────────────
|
||||||
|
|
||||||
|
it('should cache temp password in Redis with 24h TTL', async () => {
|
||||||
|
const input: CreateMemberInput = {
|
||||||
|
firstName: 'CC',
|
||||||
|
lastName: 'Agent',
|
||||||
|
email: 'ccagent@ramaiahcare.com',
|
||||||
|
password: 'CcRamaiah@2026',
|
||||||
|
roleId: 'role-001',
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.createMember(input);
|
||||||
|
|
||||||
|
// Redis setCache should be called with the temp password
|
||||||
|
expect(session.setCache).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('team:tempPassword:'),
|
||||||
|
'CcRamaiah@2026',
|
||||||
|
86400, // 24 hours
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Email normalization ──────────────────────────────────────
|
||||||
|
|
||||||
|
it('should lowercase email before signUp', async () => {
|
||||||
|
const input: CreateMemberInput = {
|
||||||
|
firstName: 'Test',
|
||||||
|
lastName: 'User',
|
||||||
|
email: 'CcAgent@RamaiahCare.COM',
|
||||||
|
password: 'pass',
|
||||||
|
roleId: 'role-001',
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.createMember(input);
|
||||||
|
|
||||||
|
const signUpCall = platform.query.mock.calls.find(
|
||||||
|
c => (c[0] as string).includes('signUpInWorkspace'),
|
||||||
|
);
|
||||||
|
expect(signUpCall![1]?.email).toBe('ccagent@ramaiahcare.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Workspace context caching ────────────────────────────────
|
||||||
|
|
||||||
|
it('should fetch workspace context only once for multiple creates', async () => {
|
||||||
|
let callCount = 0;
|
||||||
|
|
||||||
|
platform.query.mockImplementation((query: string) => {
|
||||||
|
if (query.includes('currentWorkspace')) {
|
||||||
|
return Promise.resolve({ currentWorkspace: mockWorkspace });
|
||||||
|
}
|
||||||
|
if (query.includes('signUpInWorkspace')) {
|
||||||
|
return Promise.resolve({ signUpInWorkspace: { workspace: { id: mockWorkspace.id } } });
|
||||||
|
}
|
||||||
|
if (query.includes('workspaceMembers')) {
|
||||||
|
callCount++;
|
||||||
|
// Return matching email based on call order
|
||||||
|
const email = callCount <= 1 ? 'a@test.com' : 'b@test.com';
|
||||||
|
return Promise.resolve({
|
||||||
|
workspaceMembers: {
|
||||||
|
edges: [{ node: { id: `member-${callCount}`, userId: 'u', userEmail: email } }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (query.includes('updateWorkspaceMember')) return Promise.resolve({ updateWorkspaceMember: { id: 'member-x' } });
|
||||||
|
if (query.includes('updateWorkspaceMemberRole')) return Promise.resolve({ updateWorkspaceMemberRole: { id: 'member-x' } });
|
||||||
|
return Promise.resolve({});
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.createMember({ firstName: 'A', lastName: 'B', email: 'a@test.com', password: 'p', roleId: 'r' });
|
||||||
|
await service.createMember({ firstName: 'C', lastName: 'D', email: 'b@test.com', password: 'p', roleId: 'r' });
|
||||||
|
|
||||||
|
const wsCalls = platform.query.mock.calls.filter(
|
||||||
|
c => (c[0] as string).includes('currentWorkspace'),
|
||||||
|
);
|
||||||
|
// Should only fetch workspace context once (cached)
|
||||||
|
expect(wsCalls.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
46
src/widget/captcha.guard.ts
Normal file
46
src/widget/captcha.guard.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { CanActivate, ExecutionContext, Injectable, HttpException, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
// Cloudflare Turnstile verification endpoint
|
||||||
|
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CaptchaGuard implements CanActivate {
|
||||||
|
private readonly logger = new Logger(CaptchaGuard.name);
|
||||||
|
private readonly secretKey: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.secretKey = process.env.RECAPTCHA_SECRET_KEY ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
if (!this.secretKey) {
|
||||||
|
this.logger.warn('RECAPTCHA_SECRET_KEY not set — captcha disabled');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const token = request.body?.captchaToken;
|
||||||
|
|
||||||
|
if (!token) throw new HttpException('Captcha token required', 400);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(TURNSTILE_VERIFY_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ secret: this.secretKey, response: token }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
this.logger.warn(`Captcha failed: success=${data.success} errors=${JSON.stringify(data['error-codes'] ?? [])}`);
|
||||||
|
throw new HttpException('Captcha verification failed', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err instanceof HttpException) throw err;
|
||||||
|
this.logger.error(`Captcha verification error: ${err.message}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
226
src/widget/webhooks.controller.ts
Normal file
226
src/widget/webhooks.controller.ts
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import { Controller, Post, Get, Body, Query, Logger, HttpException } from '@nestjs/common';
|
||||||
|
import { WidgetService } from './widget.service';
|
||||||
|
import { createHmac } from 'crypto';
|
||||||
|
|
||||||
|
@Controller('api/webhook')
|
||||||
|
export class WebhooksController {
|
||||||
|
private readonly logger = new Logger(WebhooksController.name);
|
||||||
|
private readonly googleWebhookKey: string;
|
||||||
|
private readonly fbVerifyToken: string;
|
||||||
|
|
||||||
|
constructor(private readonly widget: WidgetService) {
|
||||||
|
this.googleWebhookKey = process.env.GOOGLE_WEBHOOK_KEY ?? '';
|
||||||
|
this.fbVerifyToken = process.env.FB_VERIFY_TOKEN ?? 'helix-engage-verify';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Facebook / Instagram Lead Ads ───
|
||||||
|
|
||||||
|
// Webhook verification (Meta sends GET to verify endpoint)
|
||||||
|
@Get('facebook')
|
||||||
|
verifyFacebook(
|
||||||
|
@Query('hub.mode') mode: string,
|
||||||
|
@Query('hub.verify_token') token: string,
|
||||||
|
@Query('hub.challenge') challenge: string,
|
||||||
|
) {
|
||||||
|
if (mode === 'subscribe' && token === this.fbVerifyToken) {
|
||||||
|
this.logger.log('[FB] Webhook verified');
|
||||||
|
return challenge;
|
||||||
|
}
|
||||||
|
throw new HttpException('Verification failed', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receive leads from Facebook/Instagram
|
||||||
|
@Post('facebook')
|
||||||
|
async facebookLead(@Body() body: any) {
|
||||||
|
this.logger.log(`[FB] Webhook received: ${JSON.stringify(body).substring(0, 200)}`);
|
||||||
|
|
||||||
|
if (body.object !== 'page') {
|
||||||
|
return { status: 'ignored', reason: 'not a page event' };
|
||||||
|
}
|
||||||
|
|
||||||
|
let leadsCreated = 0;
|
||||||
|
|
||||||
|
for (const entry of body.entry ?? []) {
|
||||||
|
for (const change of entry.changes ?? []) {
|
||||||
|
if (change.field !== 'leadgen') continue;
|
||||||
|
|
||||||
|
const leadData = change.value;
|
||||||
|
const leadgenId = leadData.leadgen_id;
|
||||||
|
const formId = leadData.form_id;
|
||||||
|
const pageId = leadData.page_id;
|
||||||
|
|
||||||
|
this.logger.log(`[FB] Lead received: leadgen_id=${leadgenId} form_id=${formId} page_id=${pageId}`);
|
||||||
|
|
||||||
|
// Fetch full lead data from Meta Graph API
|
||||||
|
const lead = await this.fetchFacebookLead(leadgenId);
|
||||||
|
if (!lead) {
|
||||||
|
this.logger.warn(`[FB] Could not fetch lead data for ${leadgenId}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = this.extractFbField(lead, 'full_name') ?? 'Facebook Lead';
|
||||||
|
const phone = this.extractFbField(lead, 'phone_number') ?? '';
|
||||||
|
const email = this.extractFbField(lead, 'email') ?? '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.widget.createLead({
|
||||||
|
name,
|
||||||
|
phone: phone.replace(/[^0-9+]/g, ''),
|
||||||
|
interest: `Facebook Ad (form: ${formId})`,
|
||||||
|
message: email ? `Email: ${email}` : undefined,
|
||||||
|
captchaToken: 'webhook-bypass',
|
||||||
|
});
|
||||||
|
leadsCreated++;
|
||||||
|
this.logger.log(`[FB] Lead created: ${name} (${phone})`);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[FB] Lead creation failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 'ok', leadsCreated };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchFacebookLead(leadgenId: string): Promise<any | null> {
|
||||||
|
const accessToken = process.env.FB_PAGE_ACCESS_TOKEN;
|
||||||
|
if (!accessToken) {
|
||||||
|
this.logger.warn('[FB] FB_PAGE_ACCESS_TOKEN not set — cannot fetch lead details');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://graph.facebook.com/v21.0/${leadgenId}?access_token=${accessToken}`);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return res.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractFbField(lead: any, fieldName: string): string | null {
|
||||||
|
const fields = lead.field_data ?? [];
|
||||||
|
const field = fields.find((f: any) => f.name === fieldName);
|
||||||
|
return field?.values?.[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Google Ads Lead Form ───
|
||||||
|
|
||||||
|
@Post('google')
|
||||||
|
async googleLead(@Body() body: any) {
|
||||||
|
this.logger.log(`[GOOGLE] Webhook received: ${JSON.stringify(body).substring(0, 200)}`);
|
||||||
|
|
||||||
|
// Verify webhook key if configured
|
||||||
|
if (this.googleWebhookKey && body.google_key) {
|
||||||
|
const expected = createHmac('sha256', this.googleWebhookKey)
|
||||||
|
.update(body.lead_id ?? '')
|
||||||
|
.digest('hex');
|
||||||
|
// Google sends the key directly, not as HMAC — just compare
|
||||||
|
if (body.google_key !== this.googleWebhookKey) {
|
||||||
|
this.logger.warn('[GOOGLE] Invalid webhook key');
|
||||||
|
throw new HttpException('Invalid webhook key', 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTest = body.is_test === true;
|
||||||
|
const leadId = body.lead_id;
|
||||||
|
const campaignId = body.campaign_id;
|
||||||
|
const formId = body.form_id;
|
||||||
|
|
||||||
|
// Extract user data from column data
|
||||||
|
const userData = body.user_column_data ?? [];
|
||||||
|
const name = this.extractGoogleField(userData, 'FULL_NAME')
|
||||||
|
?? this.extractGoogleField(userData, 'FIRST_NAME')
|
||||||
|
?? 'Google Ad Lead';
|
||||||
|
const phone = this.extractGoogleField(userData, 'PHONE_NUMBER') ?? '';
|
||||||
|
const email = this.extractGoogleField(userData, 'EMAIL') ?? '';
|
||||||
|
const city = this.extractGoogleField(userData, 'CITY') ?? '';
|
||||||
|
|
||||||
|
this.logger.log(`[GOOGLE] Lead: ${name} | ${phone} | campaign=${campaignId} | test=${isTest}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.widget.createLead({
|
||||||
|
name,
|
||||||
|
phone: phone.replace(/[^0-9+]/g, ''),
|
||||||
|
interest: `Google Ad${isTest ? ' (TEST)' : ''} (campaign: ${campaignId ?? 'unknown'})`,
|
||||||
|
message: [email, city].filter(Boolean).join(', ') || undefined,
|
||||||
|
captchaToken: 'webhook-bypass',
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`[GOOGLE] Lead created: ${result.leadId}${isTest ? ' (test)' : ''}`);
|
||||||
|
return { status: 'ok', leadId: result.leadId, isTest };
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[GOOGLE] Lead creation failed: ${err.message}`);
|
||||||
|
throw new HttpException('Lead creation failed', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractGoogleField(columnData: any[], fieldName: string): string | null {
|
||||||
|
const field = columnData.find((f: any) => f.column_id === fieldName);
|
||||||
|
return field?.string_value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Ozonetel WhatsApp Callback ───
|
||||||
|
// Configure in Ozonetel: Chat Callback URL → https://engage-api.srv1477139.hstgr.cloud/api/webhook/whatsapp
|
||||||
|
// Payload format will be adapted once Ozonetel confirms their schema
|
||||||
|
|
||||||
|
@Post('whatsapp')
|
||||||
|
async whatsappLead(@Body() body: any) {
|
||||||
|
this.logger.log(`[WHATSAPP] Webhook received: ${JSON.stringify(body).substring(0, 300)}`);
|
||||||
|
|
||||||
|
const phone = body.from ?? body.caller_id ?? body.phone ?? body.customerNumber ?? '';
|
||||||
|
const name = body.name ?? body.customerName ?? '';
|
||||||
|
const message = body.message ?? body.text ?? body.body ?? '';
|
||||||
|
|
||||||
|
if (!phone) {
|
||||||
|
this.logger.warn('[WHATSAPP] No phone number in payload');
|
||||||
|
return { status: 'ignored', reason: 'no phone number' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.widget.createLead({
|
||||||
|
name: name || 'WhatsApp Lead',
|
||||||
|
phone: phone.replace(/[^0-9+]/g, ''),
|
||||||
|
interest: 'WhatsApp Enquiry',
|
||||||
|
message: message || undefined,
|
||||||
|
captchaToken: 'webhook-bypass',
|
||||||
|
});
|
||||||
|
this.logger.log(`[WHATSAPP] Lead created: ${result.leadId} (${phone})`);
|
||||||
|
return { status: 'ok', leadId: result.leadId };
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[WHATSAPP] Lead creation failed: ${err.message}`);
|
||||||
|
return { status: 'error', message: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Ozonetel SMS Callback ───
|
||||||
|
// Configure in Ozonetel: SMS Callback URL → https://engage-api.srv1477139.hstgr.cloud/api/webhook/sms
|
||||||
|
|
||||||
|
@Post('sms')
|
||||||
|
async smsLead(@Body() body: any) {
|
||||||
|
this.logger.log(`[SMS] Webhook received: ${JSON.stringify(body).substring(0, 300)}`);
|
||||||
|
|
||||||
|
const phone = body.from ?? body.caller_id ?? body.phone ?? body.senderNumber ?? '';
|
||||||
|
const name = body.name ?? '';
|
||||||
|
const message = body.message ?? body.text ?? body.body ?? '';
|
||||||
|
|
||||||
|
if (!phone) {
|
||||||
|
this.logger.warn('[SMS] No phone number in payload');
|
||||||
|
return { status: 'ignored', reason: 'no phone number' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.widget.createLead({
|
||||||
|
name: name || 'SMS Lead',
|
||||||
|
phone: phone.replace(/[^0-9+]/g, ''),
|
||||||
|
interest: 'SMS Enquiry',
|
||||||
|
message: message || undefined,
|
||||||
|
captchaToken: 'webhook-bypass',
|
||||||
|
});
|
||||||
|
this.logger.log(`[SMS] Lead created: ${result.leadId} (${phone})`);
|
||||||
|
return { status: 'ok', leadId: result.leadId };
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[SMS] Lead creation failed: ${err.message}`);
|
||||||
|
return { status: 'error', message: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
420
src/widget/widget-chat.service.ts
Normal file
420
src/widget/widget-chat.service.ts
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { streamText, tool, stepCountIs } from 'ai';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { LanguageModel, ModelMessage } from 'ai';
|
||||||
|
import { createAiModel, isAiConfigured, type AiProviderOpts } from '../ai/ai-provider';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { WidgetService } from './widget.service';
|
||||||
|
import { AiConfigService } from '../config/ai-config.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WidgetChatService {
|
||||||
|
private readonly logger = new Logger(WidgetChatService.name);
|
||||||
|
private readonly apiKey: string;
|
||||||
|
private knowledgeBase: string | null = null;
|
||||||
|
private kbLoadedAt = 0;
|
||||||
|
private readonly kbTtlMs = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private config: ConfigService,
|
||||||
|
private platform: PlatformGraphqlService,
|
||||||
|
private widget: WidgetService,
|
||||||
|
private aiConfig: AiConfigService,
|
||||||
|
) {
|
||||||
|
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||||
|
if (!this.hasAiModel()) {
|
||||||
|
this.logger.warn('AI not configured — widget chat will return fallback replies');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the model on demand so admin updates to provider/model take effect
|
||||||
|
// immediately. Construction is cheap (just wraps the SDK clients).
|
||||||
|
private aiOpts(): AiProviderOpts {
|
||||||
|
const cfg = this.aiConfig.getConfig();
|
||||||
|
return {
|
||||||
|
provider: cfg.provider,
|
||||||
|
model: cfg.model,
|
||||||
|
anthropicApiKey: this.config.get<string>('ai.anthropicApiKey'),
|
||||||
|
openaiApiKey: this.config.get<string>('ai.openaiApiKey'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildAiModel(): LanguageModel | null {
|
||||||
|
return createAiModel(this.aiOpts());
|
||||||
|
}
|
||||||
|
|
||||||
|
private get auth() {
|
||||||
|
return `Bearer ${this.apiKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasAiModel(): boolean {
|
||||||
|
return isAiConfigured(this.aiOpts());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find-or-create a lead by phone. Delegates to WidgetService so there's
|
||||||
|
// a single source of truth for the dedup window + lead shape across
|
||||||
|
// chat + book + contact.
|
||||||
|
async findOrCreateLead(name: string, phone: string): Promise<string> {
|
||||||
|
return this.widget.findOrCreateLeadByPhone(name, phone, {
|
||||||
|
source: 'WEBSITE',
|
||||||
|
status: 'NEW',
|
||||||
|
interestedService: 'Website Chat',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the first name of the lead's primary contact so we can greet the
|
||||||
|
// visitor by name in the system prompt. Returns 'there' on any failure.
|
||||||
|
async getLeadFirstName(leadId: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const data = await this.platform.queryWithAuth<any>(
|
||||||
|
`query($id: UUID!) {
|
||||||
|
leads(filter: { id: { eq: $id } }, first: 1) {
|
||||||
|
edges { node { id contactName { firstName } } }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ id: leadId },
|
||||||
|
this.auth,
|
||||||
|
);
|
||||||
|
const firstName = data?.leads?.edges?.[0]?.node?.contactName?.firstName;
|
||||||
|
return (typeof firstName === 'string' && firstName.trim()) || 'there';
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to fetch lead name for ${leadId}: ${err}`);
|
||||||
|
return 'there';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append an exchange to the lead's activity log. One activity record per
|
||||||
|
// user/assistant turn. Safe to call in the background (we don't block the
|
||||||
|
// stream on this).
|
||||||
|
async logExchange(leadId: string, userText: string, aiText: string): Promise<void> {
|
||||||
|
const summary = `User: ${userText}\n\nAI: ${aiText}`.slice(0, 4000);
|
||||||
|
try {
|
||||||
|
await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($data: LeadActivityCreateInput!) {
|
||||||
|
createLeadActivity(data: $data) { id }
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
leadId,
|
||||||
|
activityType: 'NOTE_ADDED',
|
||||||
|
channel: 'SYSTEM',
|
||||||
|
summary,
|
||||||
|
occurredAt: new Date().toISOString(),
|
||||||
|
performedBy: 'Website Chat',
|
||||||
|
outcome: 'SUCCESSFUL',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
this.auth,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to log chat activity for lead ${leadId}: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a compact knowledge base of doctor/department info for the system
|
||||||
|
// prompt. Cached for 5 min to avoid a doctors query on every chat.
|
||||||
|
private async getKnowledgeBase(): Promise<string> {
|
||||||
|
const now = Date.now();
|
||||||
|
if (this.knowledgeBase && now - this.kbLoadedAt < this.kbTtlMs) {
|
||||||
|
return this.knowledgeBase;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const doctors = await this.widget.getDoctors();
|
||||||
|
const byDept = new Map<string, { name: string; visitingHours?: string; clinic?: string }[]>();
|
||||||
|
for (const d of doctors) {
|
||||||
|
const dept = (d.department ?? 'Other').replace(/_/g, ' ');
|
||||||
|
if (!byDept.has(dept)) byDept.set(dept, []);
|
||||||
|
byDept.get(dept)!.push({
|
||||||
|
name: d.name,
|
||||||
|
visitingHours: d.visitingHours,
|
||||||
|
clinic: d.clinic?.clinicName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = ['DEPARTMENTS AND DOCTORS:'];
|
||||||
|
for (const [dept, docs] of byDept) {
|
||||||
|
lines.push(`\n${dept}:`);
|
||||||
|
for (const doc of docs) {
|
||||||
|
const extras: string[] = [];
|
||||||
|
if (doc.visitingHours) extras.push(doc.visitingHours);
|
||||||
|
if (doc.clinic) extras.push(doc.clinic);
|
||||||
|
lines.push(` - ${doc.name}${extras.length ? ` (${extras.join(' • ')})` : ''}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.knowledgeBase = lines.join('\n');
|
||||||
|
this.kbLoadedAt = now;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to build widget KB: ${err}`);
|
||||||
|
this.knowledgeBase = 'DEPARTMENTS AND DOCTORS: (unavailable)';
|
||||||
|
this.kbLoadedAt = now;
|
||||||
|
}
|
||||||
|
return this.knowledgeBase;
|
||||||
|
}
|
||||||
|
|
||||||
|
async buildSystemPrompt(userName: string, selectedBranch: string | null): Promise<string> {
|
||||||
|
const init = this.widget.getInitData();
|
||||||
|
const kb = await this.getKnowledgeBase();
|
||||||
|
|
||||||
|
// Branch context flips the tool-usage rules: no branch = must call
|
||||||
|
// pick_branch first; branch set = always pass it to branch-aware
|
||||||
|
// tools. We pre-render this block since the structure is dynamic
|
||||||
|
// and the template just slots it in via {{branchContext}}.
|
||||||
|
const branchContext = selectedBranch
|
||||||
|
? [
|
||||||
|
`CURRENT BRANCH: ${selectedBranch}`,
|
||||||
|
`The visitor is interested in the ${selectedBranch} branch. You MUST pass branch="${selectedBranch}"`,
|
||||||
|
'to list_departments, show_clinic_timings, show_doctors, and show_doctor_slots every time.',
|
||||||
|
].join('\n')
|
||||||
|
: [
|
||||||
|
'BRANCH STATUS: NOT SET',
|
||||||
|
'The visitor has not picked a branch yet. Before calling list_departments, show_clinic_timings,',
|
||||||
|
'show_doctors, or show_doctor_slots, you MUST call pick_branch first so the visitor can choose.',
|
||||||
|
'Only skip this if the user asks a pure general question that does not need branch-specific data.',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
return this.aiConfig.renderPrompt('widgetChat', {
|
||||||
|
hospitalName: init.brand.name,
|
||||||
|
userName,
|
||||||
|
branchContext,
|
||||||
|
knowledgeBase: kb,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streams the assistant reply as an async iterable of UIMessageChunk-shaped
|
||||||
|
// objects. The controller writes these as SSE `data: ${json}\n\n` lines
|
||||||
|
// over the HTTP response. Tools return structured payloads the widget
|
||||||
|
// frontend renders as generative-UI cards.
|
||||||
|
async *streamReply(systemPrompt: string, messages: ModelMessage[]): AsyncGenerator<any> {
|
||||||
|
const aiModel = this.buildAiModel();
|
||||||
|
if (!aiModel) throw new Error('AI not configured');
|
||||||
|
|
||||||
|
const platform = this.platform;
|
||||||
|
const widgetSvc = this.widget;
|
||||||
|
|
||||||
|
// Branch-matching now uses the doctor's full `clinics` array
|
||||||
|
// (NormalizedDoctor) since one doctor can visit multiple
|
||||||
|
// clinics under the post-rework data model. doctorMatchesBranch
|
||||||
|
// returns true if ANY of their visit-slot clinics matches.
|
||||||
|
const matchesBranch = (d: any, branch: string | undefined): boolean => {
|
||||||
|
if (!branch) return true;
|
||||||
|
const needle = branch.toLowerCase();
|
||||||
|
const clinics: Array<{ clinicName: string }> = d.clinics ?? [];
|
||||||
|
return clinics.some((c) => c.clinicName.toLowerCase().includes(needle));
|
||||||
|
};
|
||||||
|
|
||||||
|
const tools = {
|
||||||
|
pick_branch: tool({
|
||||||
|
description:
|
||||||
|
'Show the list of hospital branches so the visitor can pick which one they are interested in. Call this BEFORE any branch-sensitive tool (list_departments, show_clinic_timings, show_doctors, show_doctor_slots) when CURRENT BRANCH is NOT SET.',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
execute: async () => {
|
||||||
|
const doctors = await widgetSvc.getDoctors();
|
||||||
|
// Branches come from the union of all doctors'
|
||||||
|
// visit-slot clinics. Each (clinic × doctor) pair
|
||||||
|
// counts once toward that branch's doctor count;
|
||||||
|
// we use a Set on doctor ids to avoid double-
|
||||||
|
// counting the same doctor against the same branch
|
||||||
|
// when they have multiple slots there.
|
||||||
|
const byBranch = new Map<
|
||||||
|
string,
|
||||||
|
{ doctorIds: Set<string>; departments: Set<string> }
|
||||||
|
>();
|
||||||
|
for (const d of doctors) {
|
||||||
|
for (const c of d.clinics ?? []) {
|
||||||
|
const name = c.clinicName?.trim();
|
||||||
|
if (!name) continue;
|
||||||
|
if (!byBranch.has(name)) {
|
||||||
|
byBranch.set(name, {
|
||||||
|
doctorIds: new Set(),
|
||||||
|
departments: new Set(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const entry = byBranch.get(name)!;
|
||||||
|
if (d.id) entry.doctorIds.add(d.id);
|
||||||
|
if (d.department) entry.departments.add(String(d.department).replace(/_/g, ' '));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
branches: Array.from(byBranch.entries())
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([name, { doctorIds, departments }]) => ({
|
||||||
|
name,
|
||||||
|
doctorCount: doctorIds.size,
|
||||||
|
departmentCount: departments.size,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
list_departments: tool({
|
||||||
|
description:
|
||||||
|
'List the departments the hospital has. Use when the visitor asks what departments or specialities are available. Pass branch if CURRENT BRANCH is set.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
branch: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Branch name to filter by. Pass the CURRENT BRANCH when set.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ branch }) => {
|
||||||
|
const doctors = await widgetSvc.getDoctors();
|
||||||
|
const filtered = doctors.filter((d: any) => matchesBranch(d, branch));
|
||||||
|
const deps = Array.from(
|
||||||
|
new Set(filtered.map((d: any) => d.department).filter(Boolean)),
|
||||||
|
) as string[];
|
||||||
|
return {
|
||||||
|
branch: branch ?? null,
|
||||||
|
departments: deps.map(d => d.replace(/_/g, ' ')),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
show_clinic_timings: tool({
|
||||||
|
description:
|
||||||
|
'Show the clinic hours / visiting times for all departments with the doctors who visit during those hours. Use when the visitor asks about clinic timings, visiting hours, when the clinic is open, or what time a department is available. Pass branch if CURRENT BRANCH is set.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
branch: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Branch name to filter by. Pass the CURRENT BRANCH when set.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ branch }) => {
|
||||||
|
const doctors = await widgetSvc.getDoctors();
|
||||||
|
const filtered = doctors.filter((d: any) => matchesBranch(d, branch));
|
||||||
|
const byDept = new Map<
|
||||||
|
string,
|
||||||
|
Array<{ name: string; hours: string; clinic: string | null }>
|
||||||
|
>();
|
||||||
|
for (const d of filtered) {
|
||||||
|
const dept = (d.department ?? 'Other').replace(/_/g, ' ');
|
||||||
|
if (!byDept.has(dept)) byDept.set(dept, []);
|
||||||
|
if (d.visitingHours) {
|
||||||
|
byDept.get(dept)!.push({
|
||||||
|
name: d.name,
|
||||||
|
hours: d.visitingHours,
|
||||||
|
clinic: d.clinic?.clinicName ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
branch: branch ?? null,
|
||||||
|
departments: Array.from(byDept.entries())
|
||||||
|
.filter(([, entries]) => entries.length > 0)
|
||||||
|
.map(([name, entries]) => ({ name, entries })),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
show_doctors: tool({
|
||||||
|
description:
|
||||||
|
'Show the list of doctors in a specific department with their visiting hours and clinic. Use when the visitor asks about doctors in a department. Pass branch if CURRENT BRANCH is set.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
department: z
|
||||||
|
.string()
|
||||||
|
.describe('Department name, e.g., "Cardiology", "ENT", "General Medicine".'),
|
||||||
|
branch: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Branch name to filter by. Pass the CURRENT BRANCH when set.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ department, branch }) => {
|
||||||
|
const doctors = await widgetSvc.getDoctors();
|
||||||
|
const deptKey = department.toLowerCase().replace(/\s+/g, '').replace(/_/g, '');
|
||||||
|
const matches = doctors
|
||||||
|
.filter((d: any) => {
|
||||||
|
const key = String(d.department ?? '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, '')
|
||||||
|
.replace(/_/g, '');
|
||||||
|
return key.includes(deptKey) && matchesBranch(d, branch);
|
||||||
|
})
|
||||||
|
.map((d: any) => ({
|
||||||
|
id: d.id,
|
||||||
|
name: d.name,
|
||||||
|
specialty: d.specialty ?? null,
|
||||||
|
visitingHours: d.visitingHours ?? null,
|
||||||
|
clinic: d.clinic?.clinicName ?? null,
|
||||||
|
}));
|
||||||
|
return { department, branch: branch ?? null, doctors: matches };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
show_doctor_slots: tool({
|
||||||
|
description:
|
||||||
|
"Show today's available appointment slots for a specific doctor. Use when the visitor wants to see when a doctor is free or wants to book with a specific doctor. The date is always today — do NOT try to specify a date. Pass branch if CURRENT BRANCH is set to disambiguate doctors with the same name across branches.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
doctorName: z
|
||||||
|
.string()
|
||||||
|
.describe('Full name of the doctor, e.g., "Dr. Lakshmi Reddy".'),
|
||||||
|
branch: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Branch name to disambiguate. Pass the CURRENT BRANCH when set.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ doctorName, branch }) => {
|
||||||
|
// Always use the server's current date. Never trust anything from
|
||||||
|
// the model here — older LLMs hallucinate their training-data
|
||||||
|
// "today" and return slots for the wrong day.
|
||||||
|
const targetDate = new Date().toISOString().slice(0, 10);
|
||||||
|
const doctors = await widgetSvc.getDoctors();
|
||||||
|
const scoped = doctors.filter((d: any) => matchesBranch(d, branch));
|
||||||
|
// Fuzzy match: lowercase + strip "Dr." prefix + collapse spaces.
|
||||||
|
const norm = (s: string) =>
|
||||||
|
s.toLowerCase().replace(/^dr\.?\s*/i, '').replace(/\s+/g, ' ').trim();
|
||||||
|
const target = norm(doctorName);
|
||||||
|
const doc =
|
||||||
|
scoped.find((d: any) => norm(d.name) === target) ??
|
||||||
|
scoped.find((d: any) => norm(d.name).includes(target)) ??
|
||||||
|
scoped.find((d: any) => target.includes(norm(d.name)));
|
||||||
|
if (!doc) {
|
||||||
|
return {
|
||||||
|
doctor: null,
|
||||||
|
date: targetDate,
|
||||||
|
slots: [],
|
||||||
|
error: `No doctor matching "${doctorName}"${branch ? ` at ${branch}` : ''} was found.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const slots = await widgetSvc.getSlots(doc.id, targetDate);
|
||||||
|
return {
|
||||||
|
doctor: {
|
||||||
|
id: doc.id,
|
||||||
|
name: doc.name,
|
||||||
|
department: doc.department ?? null,
|
||||||
|
clinic: doc.clinic?.clinicName ?? null,
|
||||||
|
},
|
||||||
|
date: targetDate,
|
||||||
|
slots,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
suggest_booking: tool({
|
||||||
|
description:
|
||||||
|
'Suggest that the visitor book an appointment. Use when the conversation is trending toward booking, the user has identified a concern, or asks "how do I book".',
|
||||||
|
inputSchema: z.object({
|
||||||
|
reason: z.string().describe('Short reason why booking is a good next step.'),
|
||||||
|
department: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Suggested department, if known.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ reason, department }) => {
|
||||||
|
return { reason, department: department ?? null };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bookings / leads are not in scope for the AI — we only wire read/
|
||||||
|
// suggest tools here. The CC agent/AP engineering team can book.
|
||||||
|
void platform;
|
||||||
|
|
||||||
|
const result = streamText({
|
||||||
|
model: aiModel,
|
||||||
|
system: systemPrompt,
|
||||||
|
messages,
|
||||||
|
tools,
|
||||||
|
stopWhen: stepCountIs(4),
|
||||||
|
});
|
||||||
|
|
||||||
|
const uiStream = result.toUIMessageStream();
|
||||||
|
for await (const chunk of uiStream) {
|
||||||
|
yield chunk;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/widget/widget-key.guard.ts
Normal file
25
src/widget/widget-key.guard.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { CanActivate, ExecutionContext, Injectable, HttpException } from '@nestjs/common';
|
||||||
|
import { WidgetKeysService } from '../config/widget-keys.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WidgetKeyGuard implements CanActivate {
|
||||||
|
constructor(private readonly keys: WidgetKeysService) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const key = request.query?.key ?? request.headers['x-widget-key'];
|
||||||
|
|
||||||
|
if (!key) throw new HttpException('Widget key required', 401);
|
||||||
|
|
||||||
|
const siteKey = await this.keys.validateKey(key);
|
||||||
|
if (!siteKey) throw new HttpException('Invalid widget key', 403);
|
||||||
|
|
||||||
|
const origin = request.headers.origin ?? request.headers.referer;
|
||||||
|
if (!this.keys.validateOrigin(siteKey, origin)) {
|
||||||
|
throw new HttpException('Origin not allowed', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.widgetSiteKey = siteKey;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
172
src/widget/widget.controller.ts
Normal file
172
src/widget/widget.controller.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { Controller, Get, Post, Delete, Body, Query, Param, Req, Res, UseGuards, Logger, HttpException } from '@nestjs/common';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import type { ModelMessage } from 'ai';
|
||||||
|
import { WidgetService } from './widget.service';
|
||||||
|
import { WidgetChatService } from './widget-chat.service';
|
||||||
|
import { WidgetKeysService } from '../config/widget-keys.service';
|
||||||
|
import { WidgetKeyGuard } from './widget-key.guard';
|
||||||
|
import { CaptchaGuard } from './captcha.guard';
|
||||||
|
import type { WidgetBookRequest, WidgetLeadRequest } from './widget.types';
|
||||||
|
|
||||||
|
type ChatStartBody = { name?: string; phone?: string };
|
||||||
|
type ChatStreamBody = { leadId?: string; messages?: ModelMessage[]; branch?: string | null };
|
||||||
|
|
||||||
|
@Controller('api/widget')
|
||||||
|
export class WidgetController {
|
||||||
|
private readonly logger = new Logger(WidgetController.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly widget: WidgetService,
|
||||||
|
private readonly chat: WidgetChatService,
|
||||||
|
private readonly keys: WidgetKeysService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('init')
|
||||||
|
@UseGuards(WidgetKeyGuard)
|
||||||
|
init() {
|
||||||
|
return this.widget.getInitData();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('doctors')
|
||||||
|
@UseGuards(WidgetKeyGuard)
|
||||||
|
async doctors() {
|
||||||
|
return this.widget.getDoctors();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('slots')
|
||||||
|
@UseGuards(WidgetKeyGuard)
|
||||||
|
async slots(@Query('doctorId') doctorId: string, @Query('date') date: string) {
|
||||||
|
if (!doctorId || !date) throw new HttpException('doctorId and date required', 400);
|
||||||
|
return this.widget.getSlots(doctorId, date);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('book')
|
||||||
|
@UseGuards(WidgetKeyGuard, CaptchaGuard)
|
||||||
|
async book(@Body() body: WidgetBookRequest) {
|
||||||
|
if (!body.patientName || !body.patientPhone || !body.doctorId || !body.scheduledAt) {
|
||||||
|
throw new HttpException('patientName, patientPhone, doctorId, and scheduledAt required', 400);
|
||||||
|
}
|
||||||
|
return this.widget.bookAppointment(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('lead')
|
||||||
|
@UseGuards(WidgetKeyGuard, CaptchaGuard)
|
||||||
|
async lead(@Body() body: WidgetLeadRequest) {
|
||||||
|
if (!body.name || !body.phone) {
|
||||||
|
throw new HttpException('name and phone required', 400);
|
||||||
|
}
|
||||||
|
return this.widget.createLead(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start (or resume) a chat session. Dedups by phone in the last 24h so a
|
||||||
|
// single visitor who books + contacts + chats doesn't create three leads.
|
||||||
|
// No CaptchaGuard: the window-level gate already verified humanity, and
|
||||||
|
// Turnstile tokens are single-use so reusing them on every endpoint breaks
|
||||||
|
// the multi-action flow.
|
||||||
|
@Post('chat-start')
|
||||||
|
@UseGuards(WidgetKeyGuard)
|
||||||
|
async chatStart(@Body() body: ChatStartBody) {
|
||||||
|
if (!body.name?.trim() || !body.phone?.trim()) {
|
||||||
|
throw new HttpException('name and phone required', 400);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const leadId = await this.chat.findOrCreateLead(body.name.trim(), body.phone.trim());
|
||||||
|
return { leadId };
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`chatStart failed: ${err?.message ?? err}`);
|
||||||
|
throw new HttpException('Failed to start chat session', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream the AI reply. Requires an active leadId from chat-start. The
|
||||||
|
// conversation is logged to leadActivity after the stream completes so the
|
||||||
|
// CC agent can review the transcript when they call the visitor back.
|
||||||
|
@Post('chat')
|
||||||
|
@UseGuards(WidgetKeyGuard)
|
||||||
|
async chat_(@Req() req: Request, @Res() res: Response) {
|
||||||
|
const body = req.body as ChatStreamBody;
|
||||||
|
const leadId = body?.leadId?.trim();
|
||||||
|
const messages = body?.messages ?? [];
|
||||||
|
const selectedBranch = body?.branch?.trim() || null;
|
||||||
|
if (!leadId) {
|
||||||
|
res.status(400).json({ error: 'leadId required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!messages.length) {
|
||||||
|
res.status(400).json({ error: 'messages required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.chat.hasAiModel()) {
|
||||||
|
res.status(503).json({ error: 'AI not configured' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the last user message up-front so we can log it after the
|
||||||
|
// stream finishes (reverse-walking messages is cheap).
|
||||||
|
const lastUser = [...messages].reverse().find(m => m.role === 'user');
|
||||||
|
const userText = typeof lastUser?.content === 'string'
|
||||||
|
? lastUser.content
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Fetch the visitor's first name from the lead so the AI can personalize.
|
||||||
|
const userName = await this.chat.getLeadFirstName(leadId);
|
||||||
|
|
||||||
|
// SSE framing — each UIMessageChunk is serialized as a `data:` event.
|
||||||
|
// See AI SDK v6 UI_MESSAGE_STREAM_HEADERS for the canonical values.
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
res.setHeader('X-Accel-Buffering', 'no');
|
||||||
|
res.setHeader('X-Vercel-Ai-Ui-Message-Stream', 'v1');
|
||||||
|
|
||||||
|
let aiText = '';
|
||||||
|
try {
|
||||||
|
const systemPrompt = await this.chat.buildSystemPrompt(userName, selectedBranch);
|
||||||
|
for await (const chunk of this.chat.streamReply(systemPrompt, messages)) {
|
||||||
|
// Track accumulated text for transcript logging.
|
||||||
|
if (chunk?.type === 'text-delta' && typeof chunk.delta === 'string') {
|
||||||
|
aiText += chunk.delta;
|
||||||
|
}
|
||||||
|
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
||||||
|
}
|
||||||
|
res.write('data: [DONE]\n\n');
|
||||||
|
res.end();
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`Chat stream failed for lead ${leadId}: ${err?.message ?? err}`);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ error: 'Chat failed' });
|
||||||
|
} else {
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'error', errorText: 'Chat failed' })}\n\n`);
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire-and-forget transcript logging. We intentionally do not await
|
||||||
|
// this so the stream response is not delayed.
|
||||||
|
if (userText && aiText) {
|
||||||
|
void this.chat.logExchange(leadId, userText, aiText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key management (admin endpoints)
|
||||||
|
@Post('keys/generate')
|
||||||
|
async generateKey(@Body() body: { hospitalName: string; allowedOrigins: string[] }) {
|
||||||
|
if (!body.hospitalName) throw new HttpException('hospitalName required', 400);
|
||||||
|
const { key, siteKey } = this.keys.generateKey(body.hospitalName, body.allowedOrigins ?? []);
|
||||||
|
await this.keys.saveKey(siteKey);
|
||||||
|
return { key, siteKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('keys')
|
||||||
|
async listKeys() {
|
||||||
|
return this.keys.listKeys();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('keys/:siteId')
|
||||||
|
async revokeKey(@Param('siteId') siteId: string) {
|
||||||
|
const revoked = await this.keys.revokeKey(siteId);
|
||||||
|
if (!revoked) throw new HttpException('Key not found', 404);
|
||||||
|
return { status: 'revoked' };
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user