mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
Merge branch 'dev' into dev-main
This commit is contained in:
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"
|
||||
}
|
||||
3243
package-lock.json
generated
3243
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/openai": "^3.0.41",
|
||||
"@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/config": "^4.0.3",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
@@ -32,9 +35,12 @@
|
||||
"ai": "^6.0.116",
|
||||
"axios": "^1.13.6",
|
||||
"ioredis": "^5.10.1",
|
||||
"json-rules-engine": "^6.6.0",
|
||||
"kafkajs": "^2.2.4",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"socket.io": "^4.8.3"
|
||||
"socket.io": "^4.8.3",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { generateText, tool, stepCountIs } from 'ai';
|
||||
import type { Request, Response } from 'express';
|
||||
import { generateText, streamText, tool, stepCountIs } from 'ai';
|
||||
import type { LanguageModel } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
@@ -61,6 +62,432 @@ 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>(
|
||||
`{ agents(first: 20) { edges { node { id name ozonetelagentid npsscore maxidleminutes minnpsthreshold minconversionpercent } } } }`,
|
||||
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.minconversionpercent,
|
||||
belowNpsThreshold: agent.minnpsthreshold && (agent.npsscore ?? 100) < agent.minnpsthreshold,
|
||||
belowConversionThreshold: agent.minconversionpercent && conversionRate < agent.minconversionpercent,
|
||||
};
|
||||
});
|
||||
|
||||
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 visitingHours
|
||||
consultationFeeNew { amountMicros currencyCode }
|
||||
clinic { clinicName }
|
||||
} } } }`,
|
||||
undefined, auth,
|
||||
);
|
||||
const doctors = 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> {
|
||||
const now = Date.now();
|
||||
if (this.knowledgeBase && now - this.kbLoadedAt < this.kbTtlMs) {
|
||||
@@ -85,17 +512,18 @@ export class AiChatController {
|
||||
);
|
||||
const clinics = clinicData.clinics.edges.map((e: any) => e.node);
|
||||
if (clinics.length) {
|
||||
sections.push('## Clinics');
|
||||
sections.push('## CLINICS & TIMINGS');
|
||||
for (const c of clinics) {
|
||||
const name = c.clinicName ?? c.name;
|
||||
const addr = c.addressCustom
|
||||
? [c.addressCustom.addressStreet1, c.addressCustom.addressCity].filter(Boolean).join(', ')
|
||||
: '';
|
||||
const hours = [
|
||||
c.weekdayHours ? `Mon–Fri ${c.weekdayHours}` : '',
|
||||
c.saturdayHours ? `Sat ${c.saturdayHours}` : '',
|
||||
c.sundayHours ? `Sun ${c.sundayHours}` : 'Sun closed',
|
||||
].filter(Boolean).join(', ');
|
||||
sections.push(`- ${c.clinicName ?? c.name}: ${addr}. ${hours}.`);
|
||||
sections.push(`### ${name}`);
|
||||
if (addr) sections.push(` Address: ${addr}`);
|
||||
if (c.weekdayHours) sections.push(` Mon–Fri: ${c.weekdayHours}`);
|
||||
if (c.saturdayHours) sections.push(` Saturday: ${c.saturdayHours}`);
|
||||
sections.push(` Sunday: ${c.sundayHours ?? 'Closed'}`);
|
||||
if (c.walkInAllowed) sections.push(` Walk-ins: Accepted`);
|
||||
}
|
||||
|
||||
const rulesClinic = clinics[0];
|
||||
@@ -121,7 +549,36 @@ export class AiChatController {
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to fetch clinics: ${err}`);
|
||||
sections.push('## Clinics\nFailed to load clinic data.');
|
||||
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 {
|
||||
fullName { firstName lastName } department specialty visitingHours
|
||||
consultationFeeNew { amountMicros currencyCode }
|
||||
clinic { clinicName }
|
||||
} } } }`,
|
||||
undefined, auth,
|
||||
);
|
||||
const doctors = docData.doctors.edges.map((e: any) => e.node);
|
||||
if (doctors.length) {
|
||||
sections.push('\n## DOCTORS');
|
||||
for (const d of doctors) {
|
||||
const name = `Dr. ${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''}`.trim();
|
||||
const fee = d.consultationFeeNew ? `₹${d.consultationFeeNew.amountMicros / 1_000_000}` : '';
|
||||
const clinic = d.clinic?.clinicName ?? '';
|
||||
sections.push(`### ${name}`);
|
||||
sections.push(` Department: ${d.department ?? 'N/A'}`);
|
||||
sections.push(` Specialty: ${d.specialty ?? 'N/A'}`);
|
||||
if (d.visitingHours) sections.push(` Hours: ${d.visitingHours}`);
|
||||
if (fee) sections.push(` Consultation fee: ${fee}`);
|
||||
if (clinic) sections.push(` Clinic: ${clinic}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to fetch doctors for KB: ${err}`);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -187,20 +644,92 @@ export class AiChatController {
|
||||
return this.knowledgeBase;
|
||||
}
|
||||
|
||||
private buildSupervisorSystemPrompt(): string {
|
||||
return `You are an AI assistant for supervisors at Global Hospital's call center (Helix Engage).
|
||||
You help supervisors monitor team performance, identify issues, and make data-driven decisions.
|
||||
|
||||
## 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.`;
|
||||
}
|
||||
|
||||
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 {
|
||||
return `You are an AI assistant for call center agents at a hospital.
|
||||
You help agents answer questions about patients, doctors, appointments, and hospital services during live calls.
|
||||
return `You are an AI assistant for call center agents at Global Hospital, Bangalore.
|
||||
You help agents answer questions about patients, doctors, appointments, clinics, and hospital services during live calls.
|
||||
|
||||
IMPORTANT — ANSWER FROM KNOWLEDGE BASE FIRST:
|
||||
The knowledge base below contains REAL clinic locations, timings, doctor details, health packages, and insurance partners.
|
||||
When asked about clinic timings, locations, doctor availability, packages, or insurance — ALWAYS check the knowledge base FIRST before saying you don't know.
|
||||
Example: "What are the Koramangala timings?" → Look for "Koramangala" in the Clinics section below.
|
||||
|
||||
RULES:
|
||||
1. For patient-specific questions, 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."
|
||||
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. NEVER share sensitive hospital data (revenue, salaries, internal policies).
|
||||
8. Format with bullet points for easy scanning.
|
||||
7. Format with bullet points for easy scanning.
|
||||
|
||||
KNOWLEDGE BASE (this is real data from our system):
|
||||
${kb}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,12 @@ import { WorklistModule } from './worklist/worklist.module';
|
||||
import { CallAssistModule } from './call-assist/call-assist.module';
|
||||
import { SearchModule } from './search/search.module';
|
||||
import { SupervisorModule } from './supervisor/supervisor.module';
|
||||
import { EmbedModule } from './embed/embed.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';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -33,7 +38,12 @@ import { EmbedModule } from './embed/embed.module';
|
||||
CallAssistModule,
|
||||
SearchModule,
|
||||
SupervisorModule,
|
||||
EmbedModule,
|
||||
MaintModule,
|
||||
RecordingsModule,
|
||||
EventsModule,
|
||||
CallerResolutionModule,
|
||||
RulesEngineModule,
|
||||
ConfigThemeModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -116,55 +116,60 @@ export class AuthController {
|
||||
|
||||
this.logger.log(`User ${body.email} logged in with role: ${appRole} (platform roles: ${roleLabels.join(', ')})`);
|
||||
|
||||
// Multi-agent: resolve agent config + session lock for CC agents
|
||||
// Check if user has an Agent entity with SIP config — applies to ALL roles
|
||||
let agentConfigResponse: any = undefined;
|
||||
const memberId = workspaceMember?.id;
|
||||
|
||||
if (appRole === 'cc-agent') {
|
||||
const memberId = workspaceMember?.id;
|
||||
if (!memberId) throw new HttpException('Workspace member not found', 400);
|
||||
|
||||
if (memberId) {
|
||||
const agentConfig = await this.agentConfigService.getByMemberId(memberId);
|
||||
if (!agentConfig) {
|
||||
|
||||
if (agentConfig) {
|
||||
// Agent entity found — set up SIP + Ozonetel
|
||||
const clientIp = (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ?? req.ip ?? 'unknown';
|
||||
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 = process.env.OZONETEL_AGENT_PASSWORD ?? '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`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for duplicate login — strict: one device only
|
||||
const clientIp = (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ?? req.ip ?? 'unknown';
|
||||
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);
|
||||
}
|
||||
|
||||
// Lock session in Redis with IP
|
||||
await this.sessionService.lockSession(agentConfig.ozonetelAgentId, memberId, clientIp);
|
||||
|
||||
// Force-refresh Ozonetel API token on login
|
||||
this.ozonetelAgent.refreshToken().catch(err => {
|
||||
this.logger.warn(`Ozonetel token refresh on login failed: ${err.message}`);
|
||||
});
|
||||
|
||||
// Login to Ozonetel with agent-specific credentials
|
||||
const ozAgentPassword = process.env.OZONETEL_AGENT_PASSWORD ?? '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(`CC agent ${body.email} → Ozonetel ${agentConfig.ozonetelAgentId} / SIP ${agentConfig.sipExtension}`);
|
||||
// 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 {
|
||||
|
||||
@@ -50,4 +50,28 @@ export class SessionService implements OnModuleInit {
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,20 @@ export class CallEventsGateway {
|
||||
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
|
||||
@SubscribeMessage('agent:register')
|
||||
handleAgentRegister(
|
||||
|
||||
@@ -167,7 +167,24 @@ export class CallEventsService {
|
||||
`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 {
|
||||
await this.platform.createCall({
|
||||
callDirection: 'INBOUND',
|
||||
@@ -187,8 +204,11 @@ export class CallEventsService {
|
||||
disposition: payload.disposition,
|
||||
callNotes: payload.notes || 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) {
|
||||
this.logger.error(`Failed to create call record: ${error}`);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
10
src/config/config-theme.module.ts
Normal file
10
src/config/config-theme.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ThemeController } from './theme.controller';
|
||||
import { ThemeService } from './theme.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ThemeController],
|
||||
providers: [ThemeService],
|
||||
exports: [ThemeService],
|
||||
})
|
||||
export class ConfigThemeModule {}
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
119
src/events/consumers/ai-insight.consumer.ts
Normal file
119
src/events/consumers/ai-insight.consumer.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
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';
|
||||
|
||||
@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,
|
||||
) {
|
||||
this.aiModel = createAiModel(config);
|
||||
}
|
||||
|
||||
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: `You are a CRM assistant for Global Hospital Bangalore.
|
||||
Generate a brief, actionable insight about this lead based on their interaction history.
|
||||
Be specific — reference actual dates, dispositions, and patterns.
|
||||
If the lead has booked appointments, mention upcoming ones.
|
||||
If they keep calling about the same thing, note the pattern.`,
|
||||
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);
|
||||
}
|
||||
315
src/maint/maint.controller.ts
Normal file
315
src/maint/maint.controller.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import { Controller, Post, UseGuards, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
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';
|
||||
|
||||
@Controller('api/maint')
|
||||
@UseGuards(MaintGuard)
|
||||
export class MaintController {
|
||||
private readonly logger = new Logger(MaintController.name);
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
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 agentId = this.config.get<string>('OZONETEL_AGENT_ID') ?? 'agent3';
|
||||
const password = process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$';
|
||||
const sipId = this.config.get<string>('OZONETEL_SIP_ID') ?? '521814';
|
||||
|
||||
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.config.get<string>('OZONETEL_AGENT_ID') ?? '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 {}
|
||||
@@ -3,6 +3,8 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { OzonetelAgentService } from './ozonetel-agent.service';
|
||||
import { MissedQueueService } from '../worklist/missed-queue.service';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { EventBusService } from '../events/event-bus.service';
|
||||
import { Topics } from '../events/event-types';
|
||||
|
||||
@Controller('api/ozonetel')
|
||||
export class OzonetelAgentController {
|
||||
@@ -17,6 +19,7 @@ export class OzonetelAgentController {
|
||||
private readonly config: ConfigService,
|
||||
private readonly missedQueue: MissedQueueService,
|
||||
private readonly platform: PlatformGraphqlService,
|
||||
private readonly eventBus: EventBusService,
|
||||
) {
|
||||
this.defaultAgentId = config.get<string>('OZONETEL_AGENT_ID') ?? 'agent3';
|
||||
this.defaultAgentPassword = config.get<string>('OZONETEL_AGENT_PASSWORD') ?? '';
|
||||
@@ -65,7 +68,7 @@ export class OzonetelAgentController {
|
||||
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 {
|
||||
const result = await this.ozonetelAgent.changeAgentState({
|
||||
@@ -73,47 +76,31 @@ export class OzonetelAgentController {
|
||||
state: body.state,
|
||||
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;
|
||||
} catch (error: any) {
|
||||
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 };
|
||||
}
|
||||
|
||||
// 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')
|
||||
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);
|
||||
}
|
||||
}
|
||||
// force-ready moved to /api/maint/force-ready
|
||||
|
||||
@Post('dispose')
|
||||
async dispose(
|
||||
@@ -132,19 +119,21 @@ export class OzonetelAgentController {
|
||||
throw new HttpException('ucid and disposition required', 400);
|
||||
}
|
||||
|
||||
this.logger.log(`Dispose: ucid=${body.ucid} disposition=${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 {
|
||||
const result = await this.ozonetelAgent.setDisposition({
|
||||
agentId: this.defaultAgentId,
|
||||
ucid: body.ucid,
|
||||
disposition: ozonetelDisposition,
|
||||
});
|
||||
this.logger.log(`[DISPOSE] Ozonetel response: ${JSON.stringify(result)}`);
|
||||
} catch (error: any) {
|
||||
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
|
||||
@@ -175,6 +164,20 @@ export class OzonetelAgentController {
|
||||
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' };
|
||||
}
|
||||
|
||||
@@ -188,7 +191,7 @@ export class OzonetelAgentController {
|
||||
|
||||
const campaignName = body.campaignName ?? process.env.OZONETEL_CAMPAIGN_NAME ?? '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 {
|
||||
const result = await this.ozonetelAgent.manualDial({
|
||||
|
||||
@@ -49,6 +49,7 @@ export type CreateCallInput = {
|
||||
disposition?: string;
|
||||
callNotes?: string;
|
||||
leadId?: string;
|
||||
sla?: number;
|
||||
};
|
||||
|
||||
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 {}
|
||||
250
src/recordings/recordings.service.ts
Normal file
250
src/recordings/recordings.service.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
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';
|
||||
|
||||
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) {
|
||||
this.deepgramApiKey = process.env.DEEPGRAM_API_KEY ?? '';
|
||||
this.aiModel = createAiModel(config);
|
||||
}
|
||||
|
||||
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: `You are a call quality analyst for Global Hospital Bangalore.
|
||||
Analyze the following call recording transcript and provide structured insights.
|
||||
Be specific, brief, and actionable. Focus on healthcare context.
|
||||
${summary ? `\nCall summary: ${summary}` : ''}
|
||||
${topics.length > 0 ? `\nDetected topics: ${topics.join(', ')}` : ''}`,
|
||||
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,
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Controller, Get, Post, Body, Query, Logger } from '@nestjs/common';
|
||||
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')
|
||||
@@ -22,7 +23,7 @@ export class SupervisorController {
|
||||
@Post('call-event')
|
||||
handleCallEvent(@Body() body: any) {
|
||||
const event = body.data ?? body;
|
||||
this.logger.log(`Call event: ${event.action} ucid=${event.ucid ?? event.monitorUCID} agent=${event.agent_id ?? event.agentID}`);
|
||||
this.logger.log(`[CALL-EVENT] ${JSON.stringify(event)}`);
|
||||
this.supervisor.handleCallEvent(event);
|
||||
return { received: true };
|
||||
}
|
||||
@@ -30,8 +31,25 @@ export class SupervisorController {
|
||||
@Post('agent-event')
|
||||
handleAgentEvent(@Body() body: any) {
|
||||
const event = body.data ?? body;
|
||||
this.logger.log(`Agent event: ${event.action} agent=${event.agentId ?? event.agent_id}`);
|
||||
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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,6 @@ import { SupervisorService } from './supervisor.service';
|
||||
imports: [PlatformModule, OzonetelAgentModule],
|
||||
controllers: [SupervisorController],
|
||||
providers: [SupervisorService],
|
||||
exports: [SupervisorService],
|
||||
})
|
||||
export class SupervisorModule {}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
|
||||
@@ -12,10 +13,19 @@ type ActiveCall = {
|
||||
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,
|
||||
@@ -50,7 +60,47 @@ export class SupervisorService implements OnModuleInit {
|
||||
}
|
||||
|
||||
handleAgentEvent(event: any) {
|
||||
this.logger.log(`Agent event: ${event.agentId ?? event.agent_id} → ${event.action}`);
|
||||
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[] {
|
||||
|
||||
@@ -2,6 +2,16 @@ import { Controller, Post, Body, Headers, Logger } from '@nestjs/common';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
// Ozonetel sends all timestamps in IST — convert to UTC for storage
|
||||
function istToUtc(istDateStr: string | null): string | null {
|
||||
if (!istDateStr) return null;
|
||||
// Parse as-is, then subtract 5:30 to get UTC
|
||||
const d = new Date(istDateStr);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
d.setMinutes(d.getMinutes() - 330); // IST is UTC+5:30
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
@Controller('webhooks/ozonetel')
|
||||
export class MissedCallWebhookController {
|
||||
private readonly logger = new Logger(MissedCallWebhookController.name);
|
||||
@@ -130,8 +140,8 @@ export class MissedCallWebhookController {
|
||||
callStatus: data.callStatus,
|
||||
callerNumber: { primaryPhoneNumber: `+91${data.callerPhone}` },
|
||||
agentName: data.agentName,
|
||||
startedAt: data.startTime ? new Date(data.startTime).toISOString() : null,
|
||||
endedAt: data.endTime ? new Date(data.endTime).toISOString() : null,
|
||||
startedAt: istToUtc(data.startTime),
|
||||
endedAt: istToUtc(data.endTime),
|
||||
durationSec: data.duration,
|
||||
disposition: this.mapDisposition(data.disposition),
|
||||
};
|
||||
|
||||
@@ -3,6 +3,15 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
||||
|
||||
// Ozonetel sends all timestamps in IST — convert to UTC for storage
|
||||
export function istToUtc(istDateStr: string | null): string | null {
|
||||
if (!istDateStr) return null;
|
||||
const d = new Date(istDateStr);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
d.setMinutes(d.getMinutes() - 330); // IST is UTC+5:30
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
// Normalize phone to +91XXXXXXXXXX format
|
||||
export function normalizePhone(raw: string): string {
|
||||
let digits = raw.replace(/[^0-9]/g, '');
|
||||
@@ -61,9 +70,31 @@ export class MissedQueueService implements OnModuleInit {
|
||||
if (!phone || phone.length < 13) continue;
|
||||
|
||||
const did = call.did || '';
|
||||
const callTime = call.callTime || new Date().toISOString();
|
||||
const callTime = istToUtc(call.callTime) ?? new Date().toISOString();
|
||||
|
||||
try {
|
||||
// Look up lead by phone number — strip +91 prefix for flexible matching
|
||||
const phoneDigits = phone.replace(/^\+91/, '');
|
||||
let leadId: string | null = null;
|
||||
let leadName: string | null = null;
|
||||
try {
|
||||
const leadResult = await this.platform.query<any>(
|
||||
`{ leads(first: 1, filter: {
|
||||
contactPhone: { primaryPhoneNumber: { like: "%${phoneDigits}" } }
|
||||
}) { edges { node { id contactName { firstName lastName } patientId } } } }`,
|
||||
);
|
||||
const matchedLead = leadResult?.leads?.edges?.[0]?.node;
|
||||
if (matchedLead) {
|
||||
leadId = matchedLead.id;
|
||||
const fn = matchedLead.contactName?.firstName ?? '';
|
||||
const ln = matchedLead.contactName?.lastName ?? '';
|
||||
leadName = `${fn} ${ln}`.trim() || null;
|
||||
this.logger.log(`Matched missed call ${phone} → lead ${leadId} (${leadName})`);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Lead lookup failed for ${phone}: ${err}`);
|
||||
}
|
||||
|
||||
const existing = await this.platform.query<any>(
|
||||
`{ calls(first: 1, filter: {
|
||||
callbackstatus: { eq: PENDING_CALLBACK },
|
||||
@@ -75,29 +106,35 @@ export class MissedQueueService implements OnModuleInit {
|
||||
|
||||
if (existingNode) {
|
||||
const newCount = (existingNode.missedcallcount || 1) + 1;
|
||||
const updateParts = [
|
||||
`missedcallcount: ${newCount}`,
|
||||
`startedAt: "${callTime}"`,
|
||||
`callsourcenumber: "${did}"`,
|
||||
];
|
||||
if (leadId) updateParts.push(`leadId: "${leadId}"`);
|
||||
if (leadName) updateParts.push(`leadName: "${leadName}"`);
|
||||
await this.platform.query<any>(
|
||||
`mutation { updateCall(id: "${existingNode.id}", data: {
|
||||
missedcallcount: ${newCount},
|
||||
startedAt: "${callTime}",
|
||||
callsourcenumber: "${did}"
|
||||
}) { id } }`,
|
||||
`mutation { updateCall(id: "${existingNode.id}", data: { ${updateParts.join(', ')} }) { id } }`,
|
||||
);
|
||||
updated++;
|
||||
this.logger.log(`Dedup missed call ${phone}: count now ${newCount}`);
|
||||
this.logger.log(`Dedup missed call ${phone}: count now ${newCount}${leadName ? ` (${leadName})` : ''}`);
|
||||
} else {
|
||||
const dataParts = [
|
||||
`callStatus: MISSED`,
|
||||
`direction: INBOUND`,
|
||||
`callerNumber: { primaryPhoneNumber: "${phone}", primaryPhoneCallingCode: "+91" }`,
|
||||
`callsourcenumber: "${did}"`,
|
||||
`callbackstatus: PENDING_CALLBACK`,
|
||||
`missedcallcount: 1`,
|
||||
`startedAt: "${callTime}"`,
|
||||
];
|
||||
if (leadId) dataParts.push(`leadId: "${leadId}"`);
|
||||
if (leadName) dataParts.push(`leadName: "${leadName}"`);
|
||||
await this.platform.query<any>(
|
||||
`mutation { createCall(data: {
|
||||
callStatus: MISSED,
|
||||
direction: INBOUND,
|
||||
callerNumber: { primaryPhoneNumber: "${phone}", primaryPhoneCallingCode: "+91" },
|
||||
callsourcenumber: "${did}",
|
||||
callbackstatus: PENDING_CALLBACK,
|
||||
missedcallcount: 1,
|
||||
startedAt: "${callTime}"
|
||||
}) { id } }`,
|
||||
`mutation { createCall(data: { ${dataParts.join(', ')} }) { id } }`,
|
||||
);
|
||||
created++;
|
||||
this.logger.log(`Created missed call record for ${phone}`);
|
||||
this.logger.log(`Created missed call record for ${phone}${leadName ? ` → ${leadName}` : ''}`);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to process abandon call ${ucid}: ${err}`);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Controller, Get, Patch, Headers, Param, Body, HttpException, Logger } f
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { WorklistService } from './worklist.service';
|
||||
import { MissedQueueService } from './missed-queue.service';
|
||||
import { SessionService } from '../auth/session.service';
|
||||
|
||||
@Controller('api/worklist')
|
||||
export class WorklistController {
|
||||
@@ -11,6 +12,7 @@ export class WorklistController {
|
||||
private readonly worklist: WorklistService,
|
||||
private readonly missedQueue: MissedQueueService,
|
||||
private readonly platform: PlatformGraphqlService,
|
||||
private readonly session: SessionService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@@ -44,6 +46,12 @@ export class WorklistController {
|
||||
}
|
||||
|
||||
private async resolveAgentName(authHeader: string): Promise<string> {
|
||||
// Check cached name from login (avoids currentUser query that CC agents can't access)
|
||||
const token = authHeader.replace(/^Bearer\s+/i, '');
|
||||
const cached = await this.session.getCache(`agent:name:${token.slice(-16)}`);
|
||||
if (cached) return cached;
|
||||
|
||||
// Fallback: try querying platform (works for admin/supervisor tokens)
|
||||
try {
|
||||
const data = await this.platform.queryWithAuth<any>(
|
||||
`{ currentUser { workspaceMember { name { firstName lastName } } } }`,
|
||||
@@ -54,7 +62,7 @@ export class WorklistController {
|
||||
const full = `${name?.firstName ?? ''} ${name?.lastName ?? ''}`.trim();
|
||||
if (full) return full;
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to resolve agent name: ${err}`);
|
||||
this.logger.warn(`Failed to resolve agent name via platform: ${err}`);
|
||||
}
|
||||
throw new HttpException('Could not determine agent identity', 400);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { PlatformModule } from '../platform/platform.module';
|
||||
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { RulesEngineModule } from '../rules-engine/rules-engine.module';
|
||||
import { WorklistController } from './worklist.controller';
|
||||
import { WorklistService } from './worklist.service';
|
||||
import { MissedQueueService } from './missed-queue.service';
|
||||
@@ -8,7 +10,7 @@ import { MissedCallWebhookController } from './missed-call-webhook.controller';
|
||||
import { KookooCallbackController } from './kookoo-callback.controller';
|
||||
|
||||
@Module({
|
||||
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule)],
|
||||
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule), forwardRef(() => AuthModule), RulesEngineModule],
|
||||
controllers: [WorklistController, MissedCallWebhookController, KookooCallbackController],
|
||||
providers: [WorklistService, MissedQueueService],
|
||||
exports: [MissedQueueService],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { WorklistConsumer } from '../rules-engine/consumers/worklist.consumer';
|
||||
|
||||
export type WorklistResponse = {
|
||||
missedCalls: any[];
|
||||
@@ -12,15 +13,33 @@ export type WorklistResponse = {
|
||||
export class WorklistService {
|
||||
private readonly logger = new Logger(WorklistService.name);
|
||||
|
||||
constructor(private readonly platform: PlatformGraphqlService) {}
|
||||
constructor(
|
||||
private readonly platform: PlatformGraphqlService,
|
||||
private readonly worklistConsumer: WorklistConsumer,
|
||||
) {}
|
||||
|
||||
async getWorklist(agentName: string, authHeader: string): Promise<WorklistResponse> {
|
||||
const [missedCalls, followUps, marketingLeads] = await Promise.all([
|
||||
const [rawMissedCalls, rawFollowUps, rawMarketingLeads] = await Promise.all([
|
||||
this.getMissedCalls(agentName, authHeader),
|
||||
this.getPendingFollowUps(agentName, authHeader),
|
||||
this.getAssignedLeads(agentName, authHeader),
|
||||
]);
|
||||
|
||||
// Tag each item with a type field for the scoring engine
|
||||
const combined = [
|
||||
...rawMissedCalls.map((item: any) => ({ ...item, type: 'missed' })),
|
||||
...rawFollowUps.map((item: any) => ({ ...item, type: 'follow-up' })),
|
||||
...rawMarketingLeads.map((item: any) => ({ ...item, type: 'lead' })),
|
||||
];
|
||||
|
||||
// Score and rank via rules engine
|
||||
const scored = await this.worklistConsumer.scoreAndRank(combined);
|
||||
|
||||
// Split back into the 3 categories
|
||||
const missedCalls = scored.filter((item: any) => item.type === 'missed');
|
||||
const followUps = scored.filter((item: any) => item.type === 'follow-up');
|
||||
const marketingLeads = scored.filter((item: any) => item.type === 'lead');
|
||||
|
||||
return {
|
||||
missedCalls,
|
||||
followUps,
|
||||
|
||||
Reference in New Issue
Block a user