mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
Merge branch 'dev-main' into dev-kartik
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"
|
||||||
|
}
|
||||||
3855
package-lock.json
generated
3855
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,9 @@
|
|||||||
"@ai-sdk/anthropic": "^3.0.58",
|
"@ai-sdk/anthropic": "^3.0.58",
|
||||||
"@ai-sdk/openai": "^3.0.41",
|
"@ai-sdk/openai": "^3.0.41",
|
||||||
"@deepgram/sdk": "^5.0.0",
|
"@deepgram/sdk": "^5.0.0",
|
||||||
|
"@livekit/agents": "^1.2.1",
|
||||||
|
"@livekit/agents-plugin-google": "^1.2.1",
|
||||||
|
"@livekit/agents-plugin-silero": "^1.2.1",
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/config": "^4.0.3",
|
"@nestjs/config": "^4.0.3",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
@@ -34,9 +37,12 @@
|
|||||||
"ai": "^6.0.116",
|
"ai": "^6.0.116",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"ioredis": "^5.10.1",
|
"ioredis": "^5.10.1",
|
||||||
|
"json-rules-engine": "^6.6.0",
|
||||||
|
"kafkajs": "^2.2.4",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"socket.io": "^4.8.3"
|
"socket.io": "^4.8.3",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
import {
|
import { Controller, Post, Body, Headers, Req, Res, HttpException, Logger } from '@nestjs/common';
|
||||||
Controller,
|
|
||||||
Post,
|
|
||||||
Body,
|
|
||||||
Headers,
|
|
||||||
HttpException,
|
|
||||||
Logger,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { generateText, tool, stepCountIs } from 'ai';
|
import type { Request, Response } from 'express';
|
||||||
|
import { generateText, streamText, tool, stepCountIs } from 'ai';
|
||||||
import type { LanguageModel } from 'ai';
|
import type { LanguageModel } from 'ai';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
@@ -41,10 +35,7 @@ export class AiChatController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('chat')
|
@Post('chat')
|
||||||
async chat(
|
async chat(@Body() body: ChatRequest, @Headers('authorization') auth: string) {
|
||||||
@Body() body: ChatRequest,
|
|
||||||
@Headers('authorization') auth: string,
|
|
||||||
) {
|
|
||||||
if (!auth) throw new HttpException('Authorization required', 401);
|
if (!auth) throw new HttpException('Authorization required', 401);
|
||||||
if (!body.message?.trim()) throw new HttpException('message required', 400);
|
if (!body.message?.trim()) throw new HttpException('message required', 400);
|
||||||
|
|
||||||
@@ -60,31 +51,447 @@ export class AiChatController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!this.aiModel) {
|
if (!this.aiModel) {
|
||||||
return {
|
return { reply: await this.fallback(msg, auth), sources: ['fallback'], confidence: 'low' };
|
||||||
reply: await this.fallback(msg, auth),
|
|
||||||
sources: ['fallback'],
|
|
||||||
confidence: 'low',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.chatWithTools(`${prefix}${msg}`, auth);
|
return await this.chatWithTools(`${prefix}${msg}`, auth);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`AI chat error: ${err}`);
|
this.logger.error(`AI chat error: ${err}`);
|
||||||
|
return { reply: await this.fallback(msg, auth), sources: ['fallback'], confidence: 'low' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 {
|
return {
|
||||||
reply: await this.fallback(msg, auth),
|
name: agent.name,
|
||||||
sources: ['fallback'],
|
totalCalls,
|
||||||
confidence: 'low',
|
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> {
|
private async buildKnowledgeBase(auth: string): Promise<string> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (this.knowledgeBase && now - this.kbLoadedAt < this.kbTtlMs) {
|
if (this.knowledgeBase && now - this.kbLoadedAt < this.kbTtlMs) {
|
||||||
this.logger.log(
|
this.logger.log(`KB cache hit (${this.knowledgeBase.length} chars, age ${Math.round((now - this.kbLoadedAt) / 1000)}s)`);
|
||||||
`KB cache hit (${this.knowledgeBase.length} chars, age ${Math.round((now - this.kbLoadedAt) / 1000)}s)`,
|
|
||||||
);
|
|
||||||
return this.knowledgeBase;
|
return this.knowledgeBase;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,45 +508,34 @@ export class AiChatController {
|
|||||||
cancellationWindowHours arriveEarlyMin requiredDocuments
|
cancellationWindowHours arriveEarlyMin requiredDocuments
|
||||||
acceptsCash acceptsCard acceptsUpi
|
acceptsCash acceptsCard acceptsUpi
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined,
|
undefined, auth,
|
||||||
auth,
|
|
||||||
);
|
);
|
||||||
const clinics = clinicData.clinics.edges.map((e: any) => e.node);
|
const clinics = clinicData.clinics.edges.map((e: any) => e.node);
|
||||||
if (clinics.length) {
|
if (clinics.length) {
|
||||||
sections.push('## Clinics');
|
sections.push('## CLINICS & TIMINGS');
|
||||||
for (const c of clinics) {
|
for (const c of clinics) {
|
||||||
|
const name = c.clinicName ?? c.name;
|
||||||
const addr = c.addressCustom
|
const addr = c.addressCustom
|
||||||
? [c.addressCustom.addressStreet1, c.addressCustom.addressCity]
|
? [c.addressCustom.addressStreet1, c.addressCustom.addressCity].filter(Boolean).join(', ')
|
||||||
.filter(Boolean)
|
|
||||||
.join(', ')
|
|
||||||
: '';
|
: '';
|
||||||
const hours = [
|
sections.push(`### ${name}`);
|
||||||
c.weekdayHours ? `Mon–Fri ${c.weekdayHours}` : '',
|
if (addr) sections.push(` Address: ${addr}`);
|
||||||
c.saturdayHours ? `Sat ${c.saturdayHours}` : '',
|
if (c.weekdayHours) sections.push(` Mon–Fri: ${c.weekdayHours}`);
|
||||||
c.sundayHours ? `Sun ${c.sundayHours}` : 'Sun closed',
|
if (c.saturdayHours) sections.push(` Saturday: ${c.saturdayHours}`);
|
||||||
]
|
sections.push(` Sunday: ${c.sundayHours ?? 'Closed'}`);
|
||||||
.filter(Boolean)
|
if (c.walkInAllowed) sections.push(` Walk-ins: Accepted`);
|
||||||
.join(', ');
|
|
||||||
sections.push(`- ${c.clinicName ?? c.name}: ${addr}. ${hours}.`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rulesClinic = clinics[0];
|
const rulesClinic = clinics[0];
|
||||||
const rules: string[] = [];
|
const rules: string[] = [];
|
||||||
if (rulesClinic.cancellationWindowHours)
|
if (rulesClinic.cancellationWindowHours) rules.push(`Free cancellation up to ${rulesClinic.cancellationWindowHours}h before`);
|
||||||
rules.push(
|
if (rulesClinic.arriveEarlyMin) rules.push(`Arrive ${rulesClinic.arriveEarlyMin}min early`);
|
||||||
`Free cancellation up to ${rulesClinic.cancellationWindowHours}h before`,
|
if (rulesClinic.requiredDocuments) rules.push(`First-time patients bring ${rulesClinic.requiredDocuments}`);
|
||||||
);
|
|
||||||
if (rulesClinic.arriveEarlyMin)
|
|
||||||
rules.push(`Arrive ${rulesClinic.arriveEarlyMin}min early`);
|
|
||||||
if (rulesClinic.requiredDocuments)
|
|
||||||
rules.push(
|
|
||||||
`First-time patients bring ${rulesClinic.requiredDocuments}`,
|
|
||||||
);
|
|
||||||
if (rulesClinic.walkInAllowed) rules.push('Walk-ins accepted');
|
if (rulesClinic.walkInAllowed) rules.push('Walk-ins accepted');
|
||||||
if (rulesClinic.onlineBooking) rules.push('Online booking available');
|
if (rulesClinic.onlineBooking) rules.push('Online booking available');
|
||||||
if (rules.length) {
|
if (rules.length) {
|
||||||
sections.push('\n### Booking Rules');
|
sections.push('\n### Booking Rules');
|
||||||
sections.push(rules.map((r) => `- ${r}`).join('\n'));
|
sections.push(rules.map(r => `- ${r}`).join('\n'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const payments: string[] = [];
|
const payments: string[] = [];
|
||||||
@@ -153,7 +549,36 @@ export class AiChatController {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Failed to fetch clinics: ${err}`);
|
this.logger.warn(`Failed to fetch clinics: ${err}`);
|
||||||
sections.push('## Clinics\nFailed to load clinic data.');
|
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 {
|
try {
|
||||||
@@ -165,17 +590,14 @@ export class AiChatController {
|
|||||||
department inclusions durationMin eligibility
|
department inclusions durationMin eligibility
|
||||||
packageTests { edges { node { labTest { testName category } order } } }
|
packageTests { edges { node { labTest { testName category } order } } }
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined,
|
undefined, auth,
|
||||||
auth,
|
|
||||||
);
|
);
|
||||||
const packages = pkgData.healthPackages.edges.map((e: any) => e.node);
|
const packages = pkgData.healthPackages.edges.map((e: any) => e.node);
|
||||||
if (packages.length) {
|
if (packages.length) {
|
||||||
sections.push('\n## Health Packages');
|
sections.push('\n## Health Packages');
|
||||||
for (const p of packages) {
|
for (const p of packages) {
|
||||||
const price = p.price ? `₹${p.price.amountMicros / 1_000_000}` : '';
|
const price = p.price ? `₹${p.price.amountMicros / 1_000_000}` : '';
|
||||||
const disc = p.discountedPrice?.amountMicros
|
const disc = p.discountedPrice?.amountMicros ? ` (discounted: ₹${p.discountedPrice.amountMicros / 1_000_000})` : '';
|
||||||
? ` (discounted: ₹${p.discountedPrice.amountMicros / 1_000_000})`
|
|
||||||
: '';
|
|
||||||
const dept = p.department ? ` [${p.department}]` : '';
|
const dept = p.department ? ` [${p.department}]` : '';
|
||||||
sections.push(`- ${p.packageName ?? p.name}: ${price}${disc}${dept}`);
|
sections.push(`- ${p.packageName ?? p.name}: ${price}${disc}${dept}`);
|
||||||
const tests = p.packageTests?.edges
|
const tests = p.packageTests?.edges
|
||||||
@@ -200,16 +622,13 @@ export class AiChatController {
|
|||||||
`{ insurancePartners(first: 30, filter: { empanelmentStatus: { eq: ACTIVE } }) { edges { node {
|
`{ insurancePartners(first: 30, filter: { empanelmentStatus: { eq: ACTIVE } }) { edges { node {
|
||||||
id name insurerName tpaName settlementType planTypesAccepted
|
id name insurerName tpaName settlementType planTypesAccepted
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined,
|
undefined, auth,
|
||||||
auth,
|
|
||||||
);
|
);
|
||||||
const insurers = insData.insurancePartners.edges.map((e: any) => e.node);
|
const insurers = insData.insurancePartners.edges.map((e: any) => e.node);
|
||||||
if (insurers.length) {
|
if (insurers.length) {
|
||||||
sections.push('\n## Insurance Partners');
|
sections.push('\n## Insurance Partners');
|
||||||
const names = insurers.map((i: any) => {
|
const names = insurers.map((i: any) => {
|
||||||
const settlement = i.settlementType
|
const settlement = i.settlementType ? ` (${i.settlementType.toLowerCase()})` : '';
|
||||||
? ` (${i.settlementType.toLowerCase()})`
|
|
||||||
: '';
|
|
||||||
return `${i.insurerName ?? i.name}${settlement}`;
|
return `${i.insurerName ?? i.name}${settlement}`;
|
||||||
});
|
});
|
||||||
sections.push(names.join(', '));
|
sections.push(names.join(', '));
|
||||||
@@ -219,29 +638,98 @@ export class AiChatController {
|
|||||||
sections.push('\n## Insurance Partners\nFailed to load insurance data.');
|
sections.push('\n## Insurance Partners\nFailed to load insurance data.');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.knowledgeBase =
|
this.knowledgeBase = sections.join('\n') || 'No hospital information available yet.';
|
||||||
sections.join('\n') || 'No hospital information available yet.';
|
|
||||||
this.kbLoadedAt = now;
|
this.kbLoadedAt = now;
|
||||||
this.logger.log(
|
this.logger.log(`Knowledge base built (${this.knowledgeBase.length} chars)`);
|
||||||
`Knowledge base built (${this.knowledgeBase.length} chars)`,
|
|
||||||
);
|
|
||||||
return this.knowledgeBase;
|
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 {
|
private buildSystemPrompt(kb: string): string {
|
||||||
return `You are an AI assistant for call center agents at a hospital.
|
return `You are an AI assistant for call center agents at Global Hospital, Bangalore.
|
||||||
You help agents answer questions about patients, doctors, appointments, and hospital services during live calls.
|
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:
|
RULES:
|
||||||
1. For patient-specific questions, you MUST use lookup tools. NEVER guess patient data.
|
1. For patient-specific questions (history, appointments, calls), use the lookup tools. NEVER guess patient data.
|
||||||
2. For doctor schedule/fees, use the lookup_doctor tool to get real data from the system.
|
2. For doctor details beyond what's in the KB, use the lookup_doctor tool.
|
||||||
3. For hospital info (clinics, packages, insurance), use the knowledge base below.
|
3. For clinic info, timings, packages, insurance → answer directly from the knowledge base below.
|
||||||
4. If a tool returns no data, say "I couldn't find that in our system."
|
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.
|
5. Be concise — agents are on live calls. Under 100 words unless asked for detail.
|
||||||
6. NEVER give medical advice, diagnosis, or treatment recommendations.
|
6. NEVER give medical advice, diagnosis, or treatment recommendations.
|
||||||
7. NEVER share sensitive hospital data (revenue, salaries, internal policies).
|
7. Format with bullet points for easy scanning.
|
||||||
8. Format with bullet points for easy scanning.
|
|
||||||
|
|
||||||
|
KNOWLEDGE BASE (this is real data from our system):
|
||||||
${kb}`;
|
${kb}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,9 +737,7 @@ ${kb}`;
|
|||||||
const kb = await this.buildKnowledgeBase(auth);
|
const kb = await this.buildKnowledgeBase(auth);
|
||||||
this.logger.log(`KB content preview: ${kb.substring(0, 300)}...`);
|
this.logger.log(`KB content preview: ${kb.substring(0, 300)}...`);
|
||||||
const systemPrompt = this.buildSystemPrompt(kb);
|
const systemPrompt = this.buildSystemPrompt(kb);
|
||||||
this.logger.log(
|
this.logger.log(`System prompt length: ${systemPrompt.length} chars, user message: "${userMessage.substring(0, 100)}"`);
|
||||||
`System prompt length: ${systemPrompt.length} chars, user message: "${userMessage.substring(0, 100)}"`,
|
|
||||||
);
|
|
||||||
const platformService = this.platform;
|
const platformService = this.platform;
|
||||||
|
|
||||||
const { text, steps } = await generateText({
|
const { text, steps } = await generateText({
|
||||||
@@ -261,8 +747,7 @@ ${kb}`;
|
|||||||
stopWhen: stepCountIs(5),
|
stopWhen: stepCountIs(5),
|
||||||
tools: {
|
tools: {
|
||||||
lookup_patient: tool({
|
lookup_patient: tool({
|
||||||
description:
|
description: 'Search for a patient/lead by phone number or name. Returns their profile, lead status, AI summary, and linked patient/campaign IDs.',
|
||||||
'Search for a patient/lead by phone number or name. Returns their profile, lead status, AI summary, and linked patient/campaign IDs.',
|
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
phone: z.string().optional().describe('Phone number to search'),
|
phone: z.string().optional().describe('Phone number to search'),
|
||||||
name: z.string().optional().describe('Patient/lead name to search'),
|
name: z.string().optional().describe('Patient/lead name to search'),
|
||||||
@@ -277,8 +762,7 @@ ${kb}`;
|
|||||||
leadScore contactAttempts firstContacted lastContacted
|
leadScore contactAttempts firstContacted lastContacted
|
||||||
aiSummary aiSuggestedAction patientId campaignId
|
aiSummary aiSuggestedAction patientId campaignId
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined,
|
undefined, auth,
|
||||||
auth,
|
|
||||||
);
|
);
|
||||||
const leads = data.leads.edges.map((e: any) => e.node);
|
const leads = data.leads.edges.map((e: any) => e.node);
|
||||||
const phoneClean = (phone ?? '').replace(/\D/g, '');
|
const phoneClean = (phone ?? '').replace(/\D/g, '');
|
||||||
@@ -286,30 +770,23 @@ ${kb}`;
|
|||||||
|
|
||||||
const matched = leads.filter((l: any) => {
|
const matched = leads.filter((l: any) => {
|
||||||
if (phoneClean) {
|
if (phoneClean) {
|
||||||
const lp = (l.contactPhone?.primaryPhoneNumber ?? '').replace(
|
const lp = (l.contactPhone?.primaryPhoneNumber ?? '').replace(/\D/g, '');
|
||||||
/\D/g,
|
if (lp.endsWith(phoneClean) || phoneClean.endsWith(lp)) return true;
|
||||||
'',
|
|
||||||
);
|
|
||||||
if (lp.endsWith(phoneClean) || phoneClean.endsWith(lp))
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
if (nameClean) {
|
if (nameClean) {
|
||||||
const fn =
|
const fn = `${l.contactName?.firstName ?? ''} ${l.contactName?.lastName ?? ''}`.toLowerCase();
|
||||||
`${l.contactName?.firstName ?? ''} ${l.contactName?.lastName ?? ''}`.toLowerCase();
|
|
||||||
if (fn.includes(nameClean)) return true;
|
if (fn.includes(nameClean)) return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!matched.length)
|
if (!matched.length) return { found: false, message: 'No patient/lead found.' };
|
||||||
return { found: false, message: 'No patient/lead found.' };
|
|
||||||
return { found: true, count: matched.length, leads: matched };
|
return { found: true, count: matched.length, leads: matched };
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
lookup_appointments: tool({
|
lookup_appointments: tool({
|
||||||
description:
|
description: 'Get all appointments (past and upcoming) for a patient. Returns doctor, department, date, status, and reason.',
|
||||||
'Get all appointments (past and upcoming) for a patient. Returns doctor, department, date, status, and reason.',
|
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
patientId: z.string().describe('Patient ID'),
|
patientId: z.string().describe('Patient ID'),
|
||||||
}),
|
}),
|
||||||
@@ -319,18 +796,14 @@ ${kb}`;
|
|||||||
id name scheduledAt durationMin appointmentType status
|
id name scheduledAt durationMin appointmentType status
|
||||||
doctorName department reasonForVisit doctorId
|
doctorName department reasonForVisit doctorId
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined,
|
undefined, auth,
|
||||||
auth,
|
|
||||||
);
|
);
|
||||||
return {
|
return { appointments: data.appointments.edges.map((e: any) => e.node) };
|
||||||
appointments: data.appointments.edges.map((e: any) => e.node),
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
lookup_call_history: tool({
|
lookup_call_history: tool({
|
||||||
description:
|
description: 'Get call log for a lead — all inbound/outbound calls with dispositions and durations.',
|
||||||
'Get call log for a lead — all inbound/outbound calls with dispositions and durations.',
|
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
leadId: z.string().describe('Lead ID'),
|
leadId: z.string().describe('Lead ID'),
|
||||||
}),
|
}),
|
||||||
@@ -339,16 +812,14 @@ ${kb}`;
|
|||||||
`{ calls(first: 20, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
`{ calls(first: 20, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
||||||
id name direction callStatus agentName startedAt durationSec disposition
|
id name direction callStatus agentName startedAt durationSec disposition
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined,
|
undefined, auth,
|
||||||
auth,
|
|
||||||
);
|
);
|
||||||
return { calls: data.calls.edges.map((e: any) => e.node) };
|
return { calls: data.calls.edges.map((e: any) => e.node) };
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
lookup_lead_activities: tool({
|
lookup_lead_activities: tool({
|
||||||
description:
|
description: 'Get the full interaction timeline — status changes, calls, WhatsApp, notes, appointments.',
|
||||||
'Get the full interaction timeline — status changes, calls, WhatsApp, notes, appointments.',
|
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
leadId: z.string().describe('Lead ID'),
|
leadId: z.string().describe('Lead ID'),
|
||||||
}),
|
}),
|
||||||
@@ -357,22 +828,16 @@ ${kb}`;
|
|||||||
`{ leadActivities(first: 30, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ occurredAt: DescNullsLast }]) { edges { node {
|
`{ leadActivities(first: 30, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ occurredAt: DescNullsLast }]) { edges { node {
|
||||||
id activityType summary occurredAt performedBy channel
|
id activityType summary occurredAt performedBy channel
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined,
|
undefined, auth,
|
||||||
auth,
|
|
||||||
);
|
);
|
||||||
return {
|
return { activities: data.leadActivities.edges.map((e: any) => e.node) };
|
||||||
activities: data.leadActivities.edges.map((e: any) => e.node),
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
lookup_doctor: tool({
|
lookup_doctor: tool({
|
||||||
description:
|
description: 'Get doctor details — schedule, clinic, fees, qualifications, specialty. Search by name.',
|
||||||
'Get doctor details — schedule, clinic, fees, qualifications, specialty. Search by name.',
|
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
doctorName: z
|
doctorName: z.string().describe('Doctor name (e.g. "Patel", "Sharma")'),
|
||||||
.string()
|
|
||||||
.describe('Doctor name (e.g. "Patel", "Sharma")'),
|
|
||||||
}),
|
}),
|
||||||
execute: async ({ doctorName }) => {
|
execute: async ({ doctorName }) => {
|
||||||
const data = await platformService.queryWithAuth<any>(
|
const data = await platformService.queryWithAuth<any>(
|
||||||
@@ -385,35 +850,25 @@ ${kb}`;
|
|||||||
active registrationNumber
|
active registrationNumber
|
||||||
clinic { id name clinicName }
|
clinic { id name clinicName }
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined,
|
undefined, auth,
|
||||||
auth,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const doctors = data.doctors.edges.map((e: any) => e.node);
|
const doctors = data.doctors.edges.map((e: any) => e.node);
|
||||||
const search = doctorName.toLowerCase();
|
const search = doctorName.toLowerCase();
|
||||||
const matched = doctors.filter((d: any) => {
|
const matched = doctors.filter((d: any) => {
|
||||||
const full =
|
const full = `${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''} ${d.name ?? ''}`.toLowerCase();
|
||||||
`${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''} ${d.name ?? ''}`.toLowerCase();
|
|
||||||
return full.includes(search);
|
return full.includes(search);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!matched.length)
|
if (!matched.length) return { found: false, message: `No doctor matching "${doctorName}"` };
|
||||||
return {
|
|
||||||
found: false,
|
|
||||||
message: `No doctor matching "${doctorName}"`,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
found: true,
|
found: true,
|
||||||
doctors: matched.map((d: any) => ({
|
doctors: matched.map((d: any) => ({
|
||||||
...d,
|
...d,
|
||||||
clinicName: d.clinic?.clinicName ?? d.clinic?.name ?? 'N/A',
|
clinicName: d.clinic?.clinicName ?? d.clinic?.name ?? 'N/A',
|
||||||
feeNewFormatted: d.consultationFeeNew
|
feeNewFormatted: d.consultationFeeNew ? `₹${d.consultationFeeNew.amountMicros / 1_000_000}` : 'N/A',
|
||||||
? `₹${d.consultationFeeNew.amountMicros / 1_000_000}`
|
feeFollowUpFormatted: d.consultationFeeFollowUp ? `₹${d.consultationFeeFollowUp.amountMicros / 1_000_000}` : 'N/A',
|
||||||
: 'N/A',
|
|
||||||
feeFollowUpFormatted: d.consultationFeeFollowUp
|
|
||||||
? `₹${d.consultationFeeFollowUp.amountMicros / 1_000_000}`
|
|
||||||
: 'N/A',
|
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -421,15 +876,12 @@ ${kb}`;
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const toolCallCount = steps.filter((s) => s.toolCalls?.length).length;
|
const toolCallCount = steps.filter(s => s.toolCalls?.length).length;
|
||||||
this.logger.log(
|
this.logger.log(`Response (${text.length} chars, ${toolCallCount} tool steps)`);
|
||||||
`Response (${text.length} chars, ${toolCallCount} tool steps)`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
reply: text,
|
reply: text,
|
||||||
sources:
|
sources: toolCallCount > 0 ? ['platform_db', 'hospital_kb'] : ['hospital_kb'],
|
||||||
toolCallCount > 0 ? ['platform_db', 'hospital_kb'] : ['hospital_kb'],
|
|
||||||
confidence: 'high',
|
confidence: 'high',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -442,62 +894,37 @@ ${kb}`;
|
|||||||
consultationFeeNew { amountMicros currencyCode }
|
consultationFeeNew { amountMicros currencyCode }
|
||||||
clinic { name clinicName }
|
clinic { name clinicName }
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined,
|
undefined, auth,
|
||||||
auth,
|
|
||||||
);
|
);
|
||||||
const docs = doctors.doctors.edges.map((e: any) => e.node);
|
const docs = doctors.doctors.edges.map((e: any) => e.node);
|
||||||
const l = msg.toLowerCase();
|
const l = msg.toLowerCase();
|
||||||
|
|
||||||
const matchedDoc = docs.find((d: any) => {
|
const matchedDoc = docs.find((d: any) => {
|
||||||
const full =
|
const full = `${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''} ${d.name ?? ''}`.toLowerCase();
|
||||||
`${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''} ${d.name ?? ''}`.toLowerCase();
|
return l.split(/\s+/).some((w: string) => w.length > 2 && full.includes(w));
|
||||||
return l
|
|
||||||
.split(/\s+/)
|
|
||||||
.some((w: string) => w.length > 2 && full.includes(w));
|
|
||||||
});
|
});
|
||||||
if (matchedDoc) {
|
if (matchedDoc) {
|
||||||
const fee = matchedDoc.consultationFeeNew
|
const fee = matchedDoc.consultationFeeNew ? `₹${matchedDoc.consultationFeeNew.amountMicros / 1_000_000}` : '';
|
||||||
? `₹${matchedDoc.consultationFeeNew.amountMicros / 1_000_000}`
|
|
||||||
: '';
|
|
||||||
const clinic = matchedDoc.clinic?.clinicName ?? '';
|
const clinic = matchedDoc.clinic?.clinicName ?? '';
|
||||||
return `Dr. ${matchedDoc.fullName?.lastName ?? matchedDoc.name} (${matchedDoc.department ?? matchedDoc.specialty}): ${matchedDoc.visitingHours ?? 'hours not set'}${clinic ? ` at ${clinic}` : ''}${fee ? `. Fee: ${fee}` : ''}.`;
|
return `Dr. ${matchedDoc.fullName?.lastName ?? matchedDoc.name} (${matchedDoc.department ?? matchedDoc.specialty}): ${matchedDoc.visitingHours ?? 'hours not set'}${clinic ? ` at ${clinic}` : ''}${fee ? `. Fee: ${fee}` : ''}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (l.includes('doctor') || l.includes('available')) {
|
if (l.includes('doctor') || l.includes('available')) {
|
||||||
return (
|
return 'Doctors: ' + docs.map((d: any) =>
|
||||||
'Doctors: ' +
|
`${d.fullName?.lastName ?? d.name} (${d.department ?? d.specialty})`
|
||||||
docs
|
).join(', ') + '.';
|
||||||
.map(
|
|
||||||
(d: any) =>
|
|
||||||
`${d.fullName?.lastName ?? d.name} (${d.department ?? d.specialty})`,
|
|
||||||
)
|
|
||||||
.join(', ') +
|
|
||||||
'.'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (l.includes('package') || l.includes('checkup') || l.includes('screening')) {
|
||||||
l.includes('package') ||
|
|
||||||
l.includes('checkup') ||
|
|
||||||
l.includes('screening')
|
|
||||||
) {
|
|
||||||
const pkgs = await this.platform.queryWithAuth<any>(
|
const pkgs = await this.platform.queryWithAuth<any>(
|
||||||
`{ healthPackages(first: 20) { edges { node { packageName price { amountMicros } } } } }`,
|
`{ healthPackages(first: 20) { edges { node { packageName price { amountMicros } } } } }`,
|
||||||
undefined,
|
undefined, auth,
|
||||||
auth,
|
|
||||||
);
|
);
|
||||||
const packages = pkgs.healthPackages.edges.map((e: any) => e.node);
|
const packages = pkgs.healthPackages.edges.map((e: any) => e.node);
|
||||||
if (packages.length) {
|
if (packages.length) {
|
||||||
return (
|
return 'Packages: ' + packages.map((p: any) =>
|
||||||
'Packages: ' +
|
`${p.packageName} ₹${p.price?.amountMicros ? p.price.amountMicros / 1_000_000 : 'N/A'}`
|
||||||
packages
|
).join(' | ') + '.';
|
||||||
.map(
|
|
||||||
(p: any) =>
|
|
||||||
`${p.packageName} ₹${p.price?.amountMicros ? p.price.amountMicros / 1_000_000 : 'N/A'}`,
|
|
||||||
)
|
|
||||||
.join(' | ') +
|
|
||||||
'.'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ import { WorklistModule } from './worklist/worklist.module';
|
|||||||
import { CallAssistModule } from './call-assist/call-assist.module';
|
import { CallAssistModule } from './call-assist/call-assist.module';
|
||||||
import { SearchModule } from './search/search.module';
|
import { SearchModule } from './search/search.module';
|
||||||
import { SupervisorModule } from './supervisor/supervisor.module';
|
import { 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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -33,7 +38,12 @@ import { EmbedModule } from './embed/embed.module';
|
|||||||
CallAssistModule,
|
CallAssistModule,
|
||||||
SearchModule,
|
SearchModule,
|
||||||
SupervisorModule,
|
SupervisorModule,
|
||||||
EmbedModule,
|
MaintModule,
|
||||||
|
RecordingsModule,
|
||||||
|
EventsModule,
|
||||||
|
CallerResolutionModule,
|
||||||
|
RulesEngineModule,
|
||||||
|
ConfigThemeModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -1,12 +1,4 @@
|
|||||||
import {
|
import { Controller, Post, Body, Headers, Req, Logger, HttpException } from '@nestjs/common';
|
||||||
Controller,
|
|
||||||
Post,
|
|
||||||
Body,
|
|
||||||
Headers,
|
|
||||||
Req,
|
|
||||||
Logger,
|
|
||||||
HttpException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
@@ -28,24 +20,17 @@ export class AuthController {
|
|||||||
private agentConfigService: AgentConfigService,
|
private agentConfigService: AgentConfigService,
|
||||||
) {
|
) {
|
||||||
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
|
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
|
||||||
this.workspaceSubdomain =
|
this.workspaceSubdomain = process.env.PLATFORM_WORKSPACE_SUBDOMAIN ?? 'fortytwo-dev';
|
||||||
process.env.PLATFORM_WORKSPACE_SUBDOMAIN ?? 'fortytwo-dev';
|
this.origin = process.env.PLATFORM_ORIGIN ?? 'http://fortytwo-dev.localhost:4010';
|
||||||
this.origin =
|
|
||||||
process.env.PLATFORM_ORIGIN ?? 'http://fortytwo-dev.localhost:4010';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
async login(
|
async login(@Body() body: { email: string; password: string }, @Req() req: Request) {
|
||||||
@Body() body: { email: string; password: string },
|
|
||||||
@Req() req: Request,
|
|
||||||
) {
|
|
||||||
this.logger.log(`Login attempt for ${body.email}`);
|
this.logger.log(`Login attempt for ${body.email}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Get login token
|
// Step 1: Get login token
|
||||||
const loginRes = await axios.post(
|
const loginRes = await axios.post(this.graphqlUrl, {
|
||||||
this.graphqlUrl,
|
|
||||||
{
|
|
||||||
query: `mutation GetLoginToken($email: String!, $password: String!) {
|
query: `mutation GetLoginToken($email: String!, $password: String!) {
|
||||||
getLoginTokenFromCredentials(
|
getLoginTokenFromCredentials(
|
||||||
email: $email
|
email: $email
|
||||||
@@ -56,14 +41,12 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
variables: { email: body.email, password: body.password },
|
variables: { email: body.email, password: body.password },
|
||||||
},
|
}, {
|
||||||
{
|
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-Workspace-Subdomain': this.workspaceSubdomain,
|
'X-Workspace-Subdomain': this.workspaceSubdomain,
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (loginRes.data.errors) {
|
if (loginRes.data.errors) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
@@ -72,13 +55,10 @@ export class AuthController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const loginToken =
|
const loginToken = loginRes.data.data.getLoginTokenFromCredentials.loginToken.token;
|
||||||
loginRes.data.data.getLoginTokenFromCredentials.loginToken.token;
|
|
||||||
|
|
||||||
// Step 2: Exchange for access + refresh tokens
|
// Step 2: Exchange for access + refresh tokens
|
||||||
const tokenRes = await axios.post(
|
const tokenRes = await axios.post(this.graphqlUrl, {
|
||||||
this.graphqlUrl,
|
|
||||||
{
|
|
||||||
query: `mutation GetAuthTokens($loginToken: String!) {
|
query: `mutation GetAuthTokens($loginToken: String!) {
|
||||||
getAuthTokensFromLoginToken(
|
getAuthTokensFromLoginToken(
|
||||||
loginToken: $loginToken
|
loginToken: $loginToken
|
||||||
@@ -91,14 +71,12 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
variables: { loginToken },
|
variables: { loginToken },
|
||||||
},
|
}, {
|
||||||
{
|
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-Workspace-Subdomain': this.workspaceSubdomain,
|
'X-Workspace-Subdomain': this.workspaceSubdomain,
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (tokenRes.data.errors) {
|
if (tokenRes.data.errors) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
@@ -111,18 +89,14 @@ export class AuthController {
|
|||||||
const accessToken = tokens.accessOrWorkspaceAgnosticToken.token;
|
const accessToken = tokens.accessOrWorkspaceAgnosticToken.token;
|
||||||
|
|
||||||
// Step 3: Fetch user profile with roles
|
// Step 3: Fetch user profile with roles
|
||||||
const profileRes = await axios.post(
|
const profileRes = await axios.post(this.graphqlUrl, {
|
||||||
this.graphqlUrl,
|
|
||||||
{
|
|
||||||
query: `{ currentUser { id email workspaceMember { id name { firstName lastName } userEmail avatarUrl roles { id label } } } }`,
|
query: `{ currentUser { id email workspaceMember { id name { firstName lastName } userEmail avatarUrl roles { id label } } } }`,
|
||||||
},
|
}, {
|
||||||
{
|
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${accessToken}`,
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const currentUser = profileRes.data?.data?.currentUser;
|
const currentUser = profileRes.data?.data?.currentUser;
|
||||||
const workspaceMember = currentUser?.workspaceMember;
|
const workspaceMember = currentUser?.workspaceMember;
|
||||||
@@ -140,73 +114,38 @@ export class AuthController {
|
|||||||
appRole = email.includes('cc') ? 'cc-agent' : 'executive';
|
appRole = email.includes('cc') ? 'cc-agent' : 'executive';
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(`User ${body.email} logged in with role: ${appRole} (platform roles: ${roleLabels.join(', ')})`);
|
||||||
`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;
|
let agentConfigResponse: any = undefined;
|
||||||
|
|
||||||
if (appRole === 'cc-agent') {
|
|
||||||
const memberId = workspaceMember?.id;
|
const memberId = workspaceMember?.id;
|
||||||
if (!memberId)
|
|
||||||
throw new HttpException('Workspace member not found', 400);
|
|
||||||
|
|
||||||
const agentConfig =
|
if (memberId) {
|
||||||
await this.agentConfigService.getByMemberId(memberId);
|
const agentConfig = await this.agentConfigService.getByMemberId(memberId);
|
||||||
if (!agentConfig) {
|
|
||||||
throw new HttpException(
|
|
||||||
'Agent account not configured. Contact administrator.',
|
|
||||||
403,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for duplicate login — strict: one device only
|
if (agentConfig) {
|
||||||
const clientIp =
|
// Agent entity found — set up SIP + Ozonetel
|
||||||
(req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ??
|
const clientIp = (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ?? req.ip ?? 'unknown';
|
||||||
req.ip ??
|
const existingSession = await this.sessionService.getSession(agentConfig.ozonetelAgentId);
|
||||||
'unknown';
|
|
||||||
const existingSession = await this.sessionService.getSession(
|
|
||||||
agentConfig.ozonetelAgentId,
|
|
||||||
);
|
|
||||||
if (existingSession) {
|
if (existingSession) {
|
||||||
this.logger.warn(
|
this.logger.warn(`Duplicate login blocked for ${body.email} — session held by IP ${existingSession.ip} since ${existingSession.lockedAt}`);
|
||||||
`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);
|
||||||
);
|
|
||||||
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);
|
||||||
await this.sessionService.lockSession(
|
|
||||||
agentConfig.ozonetelAgentId,
|
|
||||||
memberId,
|
|
||||||
clientIp,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Force-refresh Ozonetel API token on login
|
this.ozonetelAgent.refreshToken().catch(err => {
|
||||||
this.ozonetelAgent.refreshToken().catch((err) => {
|
this.logger.warn(`Ozonetel token refresh on login failed: ${err.message}`);
|
||||||
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$';
|
||||||
const ozAgentPassword =
|
this.ozonetelAgent.loginAgent({
|
||||||
process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$';
|
|
||||||
this.ozonetelAgent
|
|
||||||
.loginAgent({
|
|
||||||
agentId: agentConfig.ozonetelAgentId,
|
agentId: agentConfig.ozonetelAgentId,
|
||||||
password: ozAgentPassword,
|
password: ozAgentPassword,
|
||||||
phoneNumber: agentConfig.sipExtension,
|
phoneNumber: agentConfig.sipExtension,
|
||||||
mode: 'blended',
|
mode: 'blended',
|
||||||
})
|
}).catch(err => {
|
||||||
.catch((err) => {
|
this.logger.warn(`Ozonetel agent login failed (non-blocking): ${err.message}`);
|
||||||
this.logger.warn(
|
|
||||||
`Ozonetel agent login failed (non-blocking): ${err.message}`,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
agentConfigResponse = {
|
agentConfigResponse = {
|
||||||
@@ -218,9 +157,19 @@ export class AuthController {
|
|||||||
campaignName: agentConfig.campaignName,
|
campaignName: agentConfig.campaignName,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(`Agent ${body.email} → Ozonetel ${agentConfig.ozonetelAgentId} / SIP ${agentConfig.sipExtension}`);
|
||||||
`CC agent ${body.email} → Ozonetel ${agentConfig.ozonetelAgentId} / SIP ${agentConfig.sipExtension}`,
|
} else if (appRole === 'cc-agent') {
|
||||||
);
|
// CC agent role but no Agent entity — block login
|
||||||
|
throw new HttpException('Agent account not configured. Contact administrator.', 403);
|
||||||
|
} else {
|
||||||
|
this.logger.log(`User ${body.email} has no Agent entity — SIP disabled`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache agent name for worklist resolution (avoids re-querying currentUser with user JWT)
|
||||||
|
const agentFullName = `${workspaceMember?.name?.firstName ?? ''} ${workspaceMember?.name?.lastName ?? ''}`.trim();
|
||||||
|
if (agentFullName) {
|
||||||
|
await this.sessionService.setCache(`agent:name:${accessToken.slice(-16)}`, agentFullName, 86400);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -253,9 +202,7 @@ export class AuthController {
|
|||||||
this.logger.log('Token refresh request');
|
this.logger.log('Token refresh request');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await axios.post(
|
const res = await axios.post(this.graphqlUrl, {
|
||||||
this.graphqlUrl,
|
|
||||||
{
|
|
||||||
query: `mutation RefreshToken($token: String!) {
|
query: `mutation RefreshToken($token: String!) {
|
||||||
renewToken(appToken: $token) {
|
renewToken(appToken: $token) {
|
||||||
tokens {
|
tokens {
|
||||||
@@ -265,16 +212,12 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
variables: { token: body.refreshToken },
|
variables: { token: body.refreshToken },
|
||||||
},
|
}, {
|
||||||
{
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (res.data.errors) {
|
if (res.data.errors) {
|
||||||
this.logger.warn(
|
this.logger.warn(`Token refresh failed: ${res.data.errors[0]?.message}`);
|
||||||
`Token refresh failed: ${res.data.errors[0]?.message}`,
|
|
||||||
);
|
|
||||||
throw new HttpException('Token refresh failed', 401);
|
throw new HttpException('Token refresh failed', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,15 +238,9 @@ export class AuthController {
|
|||||||
if (!auth) return { status: 'ok' };
|
if (!auth) return { status: 'ok' };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const profileRes = await axios.post(
|
const profileRes = await axios.post(this.graphqlUrl, {
|
||||||
this.graphqlUrl,
|
|
||||||
{
|
|
||||||
query: '{ currentUser { workspaceMember { id } } }',
|
query: '{ currentUser { workspaceMember { id } } }',
|
||||||
},
|
}, { headers: { 'Content-Type': 'application/json', Authorization: auth } });
|
||||||
{
|
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: auth },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const memberId = profileRes.data?.data?.currentUser?.workspaceMember?.id;
|
const memberId = profileRes.data?.data?.currentUser?.workspaceMember?.id;
|
||||||
if (!memberId) return { status: 'ok' };
|
if (!memberId) return { status: 'ok' };
|
||||||
@@ -313,14 +250,10 @@ export class AuthController {
|
|||||||
await this.sessionService.unlockSession(agentConfig.ozonetelAgentId);
|
await this.sessionService.unlockSession(agentConfig.ozonetelAgentId);
|
||||||
this.logger.log(`Session unlocked for ${agentConfig.ozonetelAgentId}`);
|
this.logger.log(`Session unlocked for ${agentConfig.ozonetelAgentId}`);
|
||||||
|
|
||||||
this.ozonetelAgent
|
this.ozonetelAgent.logoutAgent({
|
||||||
.logoutAgent({
|
|
||||||
agentId: agentConfig.ozonetelAgentId,
|
agentId: agentConfig.ozonetelAgentId,
|
||||||
password: process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$',
|
password: process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$',
|
||||||
})
|
}).catch(err => this.logger.warn(`Ozonetel logout failed: ${err.message}`));
|
||||||
.catch((err) =>
|
|
||||||
this.logger.warn(`Ozonetel logout failed: ${err.message}`),
|
|
||||||
);
|
|
||||||
|
|
||||||
this.agentConfigService.clearCache(memberId);
|
this.agentConfigService.clearCache(memberId);
|
||||||
}
|
}
|
||||||
@@ -337,20 +270,12 @@ export class AuthController {
|
|||||||
if (!auth) return { status: 'ok' };
|
if (!auth) return { status: 'ok' };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const profileRes = await axios.post(
|
const profileRes = await axios.post(this.graphqlUrl, {
|
||||||
this.graphqlUrl,
|
|
||||||
{
|
|
||||||
query: '{ currentUser { workspaceMember { id } } }',
|
query: '{ currentUser { workspaceMember { id } } }',
|
||||||
},
|
}, { headers: { 'Content-Type': 'application/json', Authorization: auth } });
|
||||||
{
|
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: auth },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const memberId = profileRes.data?.data?.currentUser?.workspaceMember?.id;
|
const memberId = profileRes.data?.data?.currentUser?.workspaceMember?.id;
|
||||||
const agentConfig = memberId
|
const agentConfig = memberId ? this.agentConfigService.getFromCache(memberId) : null;
|
||||||
? this.agentConfigService.getFromCache(memberId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (agentConfig) {
|
if (agentConfig) {
|
||||||
await this.sessionService.refreshSession(agentConfig.ozonetelAgentId);
|
await this.sessionService.refreshSession(agentConfig.ozonetelAgentId);
|
||||||
|
|||||||
@@ -15,31 +15,19 @@ export class SessionService implements OnModuleInit {
|
|||||||
const url = this.config.get<string>('redis.url', 'redis://localhost:6379');
|
const url = this.config.get<string>('redis.url', 'redis://localhost:6379');
|
||||||
this.redis = new Redis(url);
|
this.redis = new Redis(url);
|
||||||
this.redis.on('connect', () => this.logger.log('Redis connected'));
|
this.redis.on('connect', () => this.logger.log('Redis connected'));
|
||||||
this.redis.on('error', (err) =>
|
this.redis.on('error', (err) => this.logger.error(`Redis error: ${err.message}`));
|
||||||
this.logger.error(`Redis error: ${err.message}`),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private key(agentId: string): string {
|
private key(agentId: string): string {
|
||||||
return `agent:session:${agentId}`;
|
return `agent:session:${agentId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async lockSession(
|
async lockSession(agentId: string, memberId: string, ip?: string): Promise<void> {
|
||||||
agentId: string,
|
const value = JSON.stringify({ memberId, ip: ip ?? 'unknown', lockedAt: new Date().toISOString() });
|
||||||
memberId: string,
|
|
||||||
ip?: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const value = JSON.stringify({
|
|
||||||
memberId,
|
|
||||||
ip: ip ?? 'unknown',
|
|
||||||
lockedAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
await this.redis.set(this.key(agentId), value, 'EX', SESSION_TTL);
|
await this.redis.set(this.key(agentId), value, 'EX', SESSION_TTL);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSession(
|
async getSession(agentId: string): Promise<{ memberId: string; ip: string; lockedAt: string } | null> {
|
||||||
agentId: string,
|
|
||||||
): Promise<{ memberId: string; ip: string; lockedAt: string } | null> {
|
|
||||||
const raw = await this.redis.get(this.key(agentId));
|
const raw = await this.redis.get(this.key(agentId));
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
try {
|
try {
|
||||||
@@ -62,4 +50,28 @@ export class SessionService implements OnModuleInit {
|
|||||||
async unlockSession(agentId: string): Promise<void> {
|
async unlockSession(agentId: string): Promise<void> {
|
||||||
await this.redis.del(this.key(agentId));
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,7 @@ import {
|
|||||||
} from '@nestjs/websockets';
|
} from '@nestjs/websockets';
|
||||||
import { Logger, Inject, forwardRef } from '@nestjs/common';
|
import { Logger, Inject, forwardRef } from '@nestjs/common';
|
||||||
import { Server, Socket } from 'socket.io';
|
import { Server, Socket } from 'socket.io';
|
||||||
import type {
|
import type { EnrichedCallEvent, DispositionPayload } from './call-events.types';
|
||||||
EnrichedCallEvent,
|
|
||||||
DispositionPayload,
|
|
||||||
} from './call-events.types';
|
|
||||||
import { CallEventsService } from './call-events.service';
|
import { CallEventsService } from './call-events.service';
|
||||||
|
|
||||||
@WebSocketGateway({
|
@WebSocketGateway({
|
||||||
@@ -38,6 +35,20 @@ export class CallEventsGateway {
|
|||||||
this.server.to(room).emit('call:incoming', event);
|
this.server.to(room).emit('call:incoming', event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Broadcast to supervisors when a new call record is created
|
||||||
|
broadcastCallCreated(callData: any) {
|
||||||
|
this.logger.log('Broadcasting call:created to supervisor room');
|
||||||
|
this.server.to('supervisor').emit('call:created', callData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supervisor registers to receive real-time updates
|
||||||
|
@SubscribeMessage('supervisor:register')
|
||||||
|
handleSupervisorRegister(@ConnectedSocket() client: Socket) {
|
||||||
|
client.join('supervisor');
|
||||||
|
this.logger.log(`Supervisor registered (socket: ${client.id})`);
|
||||||
|
client.emit('supervisor:registered', { room: 'supervisor' });
|
||||||
|
}
|
||||||
|
|
||||||
// Agent registers when they open the Call Desk page
|
// Agent registers when they open the Call Desk page
|
||||||
@SubscribeMessage('agent:register')
|
@SubscribeMessage('agent:register')
|
||||||
handleAgentRegister(
|
handleAgentRegister(
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ export class CallEventsService {
|
|||||||
`Matched lead: ${lead.contactName?.firstName} ${lead.contactName?.lastName} (${lead.id})`,
|
`Matched lead: ${lead.contactName?.firstName} ${lead.contactName?.lastName} (${lead.id})`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.logger.log(`No lead found for phone ${callEvent.callerPhone}`);
|
this.logger.log(
|
||||||
|
`No lead found for phone ${callEvent.callerPhone}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Lead lookup failed: ${error}`);
|
this.logger.error(`Lead lookup failed: ${error}`);
|
||||||
@@ -52,7 +54,10 @@ export class CallEventsService {
|
|||||||
// 2. AI enrichment (if lead found and no existing summary)
|
// 2. AI enrichment (if lead found and no existing summary)
|
||||||
if (lead && !lead.aiSummary) {
|
if (lead && !lead.aiSummary) {
|
||||||
try {
|
try {
|
||||||
const activities = await this.platform.getLeadActivities(lead.id, 5);
|
const activities = await this.platform.getLeadActivities(
|
||||||
|
lead.id,
|
||||||
|
5,
|
||||||
|
);
|
||||||
const enrichment = await this.ai.enrichLead({
|
const enrichment = await this.ai.enrichLead({
|
||||||
firstName: lead.contactName?.firstName,
|
firstName: lead.contactName?.firstName,
|
||||||
lastName: lead.contactName?.lastName,
|
lastName: lead.contactName?.lastName,
|
||||||
@@ -87,7 +92,10 @@ export class CallEventsService {
|
|||||||
}[] = [];
|
}[] = [];
|
||||||
if (lead) {
|
if (lead) {
|
||||||
try {
|
try {
|
||||||
const activities = await this.platform.getLeadActivities(lead.id, 3);
|
const activities = await this.platform.getLeadActivities(
|
||||||
|
lead.id,
|
||||||
|
3,
|
||||||
|
);
|
||||||
recentActivities = activities.map((a) => ({
|
recentActivities = activities.map((a) => ({
|
||||||
activityType: a.activityType ?? '',
|
activityType: a.activityType ?? '',
|
||||||
summary: a.summary ?? '',
|
summary: a.summary ?? '',
|
||||||
@@ -121,10 +129,12 @@ export class CallEventsService {
|
|||||||
email: lead.contactEmail?.[0]?.address,
|
email: lead.contactEmail?.[0]?.address,
|
||||||
source: lead.leadSource ?? undefined,
|
source: lead.leadSource ?? undefined,
|
||||||
status: lead.leadStatus ?? undefined,
|
status: lead.leadStatus ?? undefined,
|
||||||
interestedService: lead.interestedService ?? undefined,
|
interestedService:
|
||||||
|
lead.interestedService ?? undefined,
|
||||||
age: daysSinceCreation,
|
age: daysSinceCreation,
|
||||||
aiSummary: lead.aiSummary ?? undefined,
|
aiSummary: lead.aiSummary ?? undefined,
|
||||||
aiSuggestedAction: lead.aiSuggestedAction ?? undefined,
|
aiSuggestedAction:
|
||||||
|
lead.aiSuggestedAction ?? undefined,
|
||||||
recentActivities,
|
recentActivities,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
@@ -157,7 +167,24 @@ export class CallEventsService {
|
|||||||
`Processing disposition: ${payload.disposition} for call ${payload.callSid}`,
|
`Processing disposition: ${payload.disposition} for call ${payload.callSid}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 1. Create Call record in platform
|
// 1. Compute SLA % if lead is linked
|
||||||
|
let sla: number | undefined;
|
||||||
|
if (payload.leadId && payload.startedAt) {
|
||||||
|
try {
|
||||||
|
const lead = await this.platform.findLeadById(payload.leadId);
|
||||||
|
if (lead?.createdAt) {
|
||||||
|
const leadCreated = new Date(lead.createdAt).getTime();
|
||||||
|
const callStarted = new Date(payload.startedAt).getTime();
|
||||||
|
const elapsedMin = Math.max(0, (callStarted - leadCreated) / 60000);
|
||||||
|
const slaThresholdMin = 1440; // Default 24h; missed calls use 720 but this is a completed call
|
||||||
|
sla = Math.round((elapsedMin / slaThresholdMin) * 100);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// SLA computation is best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create Call record in platform
|
||||||
try {
|
try {
|
||||||
await this.platform.createCall({
|
await this.platform.createCall({
|
||||||
callDirection: 'INBOUND',
|
callDirection: 'INBOUND',
|
||||||
@@ -177,8 +204,11 @@ export class CallEventsService {
|
|||||||
disposition: payload.disposition,
|
disposition: payload.disposition,
|
||||||
callNotes: payload.notes || undefined,
|
callNotes: payload.notes || undefined,
|
||||||
leadId: payload.leadId || undefined,
|
leadId: payload.leadId || undefined,
|
||||||
|
sla,
|
||||||
});
|
});
|
||||||
this.logger.log(`Call record created for ${payload.callSid}`);
|
this.logger.log(`Call record created for ${payload.callSid} (SLA: ${sla ?? 'N/A'}%)`);
|
||||||
|
// Notify supervisors in real-time
|
||||||
|
this.gateway.broadcastCallCreated({ callSid: payload.callSid, agentName: payload.agentName, disposition: payload.disposition });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to create call record: ${error}`);
|
this.logger.error(`Failed to create call record: ${error}`);
|
||||||
}
|
}
|
||||||
@@ -211,9 +241,13 @@ export class CallEventsService {
|
|||||||
durationSeconds: payload.duration,
|
durationSeconds: payload.duration,
|
||||||
leadId: payload.leadId,
|
leadId: payload.leadId,
|
||||||
});
|
});
|
||||||
this.logger.log(`Lead activity logged for ${payload.leadId}`);
|
this.logger.log(
|
||||||
|
`Lead activity logged for ${payload.leadId}`,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to create lead activity: ${error}`);
|
this.logger.error(
|
||||||
|
`Failed to create lead activity: ${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 {}
|
||||||
@@ -1,16 +1,10 @@
|
|||||||
import {
|
import { Controller, Post, Get, Body, Query, Logger, HttpException } from '@nestjs/common';
|
||||||
Controller,
|
|
||||||
Post,
|
|
||||||
Get,
|
|
||||||
Body,
|
|
||||||
Query,
|
|
||||||
Logger,
|
|
||||||
HttpException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { OzonetelAgentService } from './ozonetel-agent.service';
|
import { OzonetelAgentService } from './ozonetel-agent.service';
|
||||||
import { MissedQueueService } from '../worklist/missed-queue.service';
|
import { MissedQueueService } from '../worklist/missed-queue.service';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { EventBusService } from '../events/event-bus.service';
|
||||||
|
import { Topics } from '../events/event-types';
|
||||||
|
|
||||||
@Controller('api/ozonetel')
|
@Controller('api/ozonetel')
|
||||||
export class OzonetelAgentController {
|
export class OzonetelAgentController {
|
||||||
@@ -25,22 +19,16 @@ export class OzonetelAgentController {
|
|||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly missedQueue: MissedQueueService,
|
private readonly missedQueue: MissedQueueService,
|
||||||
private readonly platform: PlatformGraphqlService,
|
private readonly platform: PlatformGraphqlService,
|
||||||
|
private readonly eventBus: EventBusService,
|
||||||
) {
|
) {
|
||||||
this.defaultAgentId = config.get<string>('OZONETEL_AGENT_ID') ?? 'agent3';
|
this.defaultAgentId = config.get<string>('OZONETEL_AGENT_ID') ?? 'agent3';
|
||||||
this.defaultAgentPassword =
|
this.defaultAgentPassword = config.get<string>('OZONETEL_AGENT_PASSWORD') ?? '';
|
||||||
config.get<string>('OZONETEL_AGENT_PASSWORD') ?? '';
|
|
||||||
this.defaultSipId = config.get<string>('OZONETEL_SIP_ID') ?? '521814';
|
this.defaultSipId = config.get<string>('OZONETEL_SIP_ID') ?? '521814';
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('agent-login')
|
@Post('agent-login')
|
||||||
async agentLogin(
|
async agentLogin(
|
||||||
@Body()
|
@Body() body: { agentId: string; password: string; phoneNumber: string; mode?: string },
|
||||||
body: {
|
|
||||||
agentId: string;
|
|
||||||
password: string;
|
|
||||||
phoneNumber: string;
|
|
||||||
mode?: string;
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
this.logger.log(`Agent login request for ${body.agentId}`);
|
this.logger.log(`Agent login request for ${body.agentId}`);
|
||||||
|
|
||||||
@@ -56,7 +44,9 @@ export class OzonetelAgentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('agent-logout')
|
@Post('agent-logout')
|
||||||
async agentLogout(@Body() body: { agentId: string; password: string }) {
|
async agentLogout(
|
||||||
|
@Body() body: { agentId: string; password: string },
|
||||||
|
) {
|
||||||
this.logger.log(`Agent logout request for ${body.agentId}`);
|
this.logger.log(`Agent logout request for ${body.agentId}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -78,9 +68,7 @@ export class OzonetelAgentController {
|
|||||||
throw new HttpException('state required', 400);
|
throw new HttpException('state required', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(`[AGENT-STATE] ${this.defaultAgentId} → ${body.state} (${body.pauseReason ?? 'none'})`);
|
||||||
`Agent state change: ${this.defaultAgentId} → ${body.state} (${body.pauseReason ?? ''})`,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.ozonetelAgent.changeAgentState({
|
const result = await this.ozonetelAgent.changeAgentState({
|
||||||
@@ -88,60 +76,35 @@ export class OzonetelAgentController {
|
|||||||
state: body.state,
|
state: body.state,
|
||||||
pauseReason: body.pauseReason,
|
pauseReason: body.pauseReason,
|
||||||
});
|
});
|
||||||
return result;
|
this.logger.log(`[AGENT-STATE] Ozonetel response: ${JSON.stringify(result)}`);
|
||||||
} catch (error: any) {
|
|
||||||
const message =
|
|
||||||
error.response?.data?.message ?? error.message ?? 'State change failed';
|
|
||||||
return { status: 'error', message };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-assign missed call when agent goes Ready
|
// Auto-assign missed call when agent goes Ready
|
||||||
if (body.state === 'Ready') {
|
if (body.state === 'Ready') {
|
||||||
try {
|
try {
|
||||||
const assigned = await this.missedQueue.assignNext(this.defaultAgentId);
|
const assigned = await this.missedQueue.assignNext(this.defaultAgentId);
|
||||||
if (assigned) {
|
if (assigned) {
|
||||||
return {
|
this.logger.log(`[AGENT-STATE] Auto-assigned missed call ${assigned.id}`);
|
||||||
status: 'ok',
|
return { ...result, assignedCall: assigned };
|
||||||
message: `State changed to Ready. Assigned missed call ${assigned.id}`,
|
|
||||||
assignedCall: assigned,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Auto-assignment on Ready failed: ${err}`);
|
this.logger.warn(`[AGENT-STATE] 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;
|
return result;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const message =
|
const message = error.response?.data?.message ?? error.message ?? 'State change failed';
|
||||||
error.response?.data?.message ?? error.message ?? 'Force ready failed';
|
const responseData = error.response?.data ? JSON.stringify(error.response.data) : '';
|
||||||
this.logger.error(`Force ready failed: ${message}`);
|
this.logger.error(`[AGENT-STATE] FAILED: ${message} ${responseData}`);
|
||||||
throw new HttpException(message, error.response?.status ?? 502);
|
return { status: 'error', message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// force-ready moved to /api/maint/force-ready
|
||||||
|
|
||||||
@Post('dispose')
|
@Post('dispose')
|
||||||
async dispose(
|
async dispose(
|
||||||
@Body()
|
@Body() body: {
|
||||||
body: {
|
|
||||||
ucid: string;
|
ucid: string;
|
||||||
disposition: string;
|
disposition: string;
|
||||||
callerPhone?: string;
|
callerPhone?: string;
|
||||||
@@ -156,22 +119,21 @@ export class OzonetelAgentController {
|
|||||||
throw new HttpException('ucid and disposition required', 400);
|
throw new HttpException('ucid and disposition required', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Dispose: ucid=${body.ucid} disposition=${body.disposition}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const ozonetelDisposition = this.mapToOzonetelDisposition(body.disposition);
|
const ozonetelDisposition = this.mapToOzonetelDisposition(body.disposition);
|
||||||
|
|
||||||
|
this.logger.log(`[DISPOSE] ucid=${body.ucid} disposition=${body.disposition} → ozonetel="${ozonetelDisposition}" agentId=${this.defaultAgentId} callerPhone=${body.callerPhone ?? 'none'} direction=${body.direction ?? 'unknown'} leadId=${body.leadId ?? 'none'}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.ozonetelAgent.setDisposition({
|
const result = await this.ozonetelAgent.setDisposition({
|
||||||
agentId: this.defaultAgentId,
|
agentId: this.defaultAgentId,
|
||||||
ucid: body.ucid,
|
ucid: body.ucid,
|
||||||
disposition: ozonetelDisposition,
|
disposition: ozonetelDisposition,
|
||||||
});
|
});
|
||||||
|
this.logger.log(`[DISPOSE] Ozonetel response: ${JSON.stringify(result)}`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const message =
|
const message = error.response?.data?.message ?? error.message ?? 'Disposition failed';
|
||||||
error.response?.data?.message ?? error.message ?? 'Disposition failed';
|
const responseData = error.response?.data ? JSON.stringify(error.response.data) : '';
|
||||||
this.logger.error(`Dispose failed: ${message}`);
|
this.logger.error(`[DISPOSE] FAILED: ${message} ${responseData}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle missed call callback status update
|
// Handle missed call callback status update
|
||||||
@@ -202,30 +164,34 @@ export class OzonetelAgentController {
|
|||||||
this.logger.warn(`Auto-assignment after dispose failed: ${err}`);
|
this.logger.warn(`Auto-assignment after dispose failed: ${err}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit event for downstream processing (AI insights, metrics, etc.)
|
||||||
|
this.eventBus.emit(Topics.CALL_COMPLETED, {
|
||||||
|
callId: null,
|
||||||
|
ucid: body.ucid,
|
||||||
|
agentId: this.defaultAgentId,
|
||||||
|
callerPhone: body.callerPhone ?? '',
|
||||||
|
direction: body.direction ?? 'INBOUND',
|
||||||
|
durationSec: body.durationSec ?? 0,
|
||||||
|
disposition: body.disposition,
|
||||||
|
leadId: body.leadId ?? null,
|
||||||
|
notes: body.notes ?? null,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
return { status: 'ok' };
|
return { status: 'ok' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('dial')
|
@Post('dial')
|
||||||
async dial(
|
async dial(
|
||||||
@Body()
|
@Body() body: { phoneNumber: string; campaignName?: string; leadId?: string },
|
||||||
body: {
|
|
||||||
phoneNumber: string;
|
|
||||||
campaignName?: string;
|
|
||||||
leadId?: string;
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
if (!body.phoneNumber) {
|
if (!body.phoneNumber) {
|
||||||
throw new HttpException('phoneNumber required', 400);
|
throw new HttpException('phoneNumber required', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const campaignName =
|
const campaignName = body.campaignName ?? process.env.OZONETEL_CAMPAIGN_NAME ?? 'Inbound_918041763265';
|
||||||
body.campaignName ??
|
|
||||||
process.env.OZONETEL_CAMPAIGN_NAME ??
|
|
||||||
'Inbound_918041763265';
|
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(`[DIAL] phone=${body.phoneNumber} campaign=${campaignName} agentId=${this.defaultAgentId} lead=${body.leadId ?? 'none'}`);
|
||||||
`Manual dial: ${body.phoneNumber} campaign=${campaignName} (lead: ${body.leadId ?? 'none'})`,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.ozonetelAgent.manualDial({
|
const result = await this.ozonetelAgent.manualDial({
|
||||||
@@ -235,23 +201,15 @@ export class OzonetelAgentController {
|
|||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const message =
|
const message = error.response?.data?.message ?? error.message ?? 'Dial failed';
|
||||||
error.response?.data?.message ?? error.message ?? 'Dial failed';
|
|
||||||
throw new HttpException(message, error.response?.status ?? 502);
|
throw new HttpException(message, error.response?.status ?? 502);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('call-control')
|
@Post('call-control')
|
||||||
async callControl(
|
async callControl(
|
||||||
@Body()
|
@Body() body: {
|
||||||
body: {
|
action: 'CONFERENCE' | 'HOLD' | 'UNHOLD' | 'MUTE' | 'UNMUTE' | 'KICK_CALL';
|
||||||
action:
|
|
||||||
| 'CONFERENCE'
|
|
||||||
| 'HOLD'
|
|
||||||
| 'UNHOLD'
|
|
||||||
| 'MUTE'
|
|
||||||
| 'UNMUTE'
|
|
||||||
| 'KICK_CALL';
|
|
||||||
ucid: string;
|
ucid: string;
|
||||||
conferenceNumber?: string;
|
conferenceNumber?: string;
|
||||||
},
|
},
|
||||||
@@ -260,10 +218,7 @@ export class OzonetelAgentController {
|
|||||||
throw new HttpException('action and ucid required', 400);
|
throw new HttpException('action and ucid required', 400);
|
||||||
}
|
}
|
||||||
if (body.action === 'CONFERENCE' && !body.conferenceNumber) {
|
if (body.action === 'CONFERENCE' && !body.conferenceNumber) {
|
||||||
throw new HttpException(
|
throw new HttpException('conferenceNumber required for CONFERENCE action', 400);
|
||||||
'conferenceNumber required for CONFERENCE action',
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`Call control: ${body.action} ucid=${body.ucid}`);
|
this.logger.log(`Call control: ${body.action} ucid=${body.ucid}`);
|
||||||
@@ -272,14 +227,15 @@ export class OzonetelAgentController {
|
|||||||
const result = await this.ozonetelAgent.callControl(body);
|
const result = await this.ozonetelAgent.callControl(body);
|
||||||
return result;
|
return result;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const message =
|
const message = error.response?.data?.message ?? error.message ?? 'Call control failed';
|
||||||
error.response?.data?.message ?? error.message ?? 'Call control failed';
|
|
||||||
throw new HttpException(message, error.response?.status ?? 502);
|
throw new HttpException(message, error.response?.status ?? 502);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('recording')
|
@Post('recording')
|
||||||
async recording(@Body() body: { ucid: string; action: 'pause' | 'unPause' }) {
|
async recording(
|
||||||
|
@Body() body: { ucid: string; action: 'pause' | 'unPause' },
|
||||||
|
) {
|
||||||
if (!body.ucid || !body.action) {
|
if (!body.ucid || !body.action) {
|
||||||
throw new HttpException('ucid and action required', 400);
|
throw new HttpException('ucid and action required', 400);
|
||||||
}
|
}
|
||||||
@@ -288,10 +244,7 @@ export class OzonetelAgentController {
|
|||||||
const result = await this.ozonetelAgent.pauseRecording(body);
|
const result = await this.ozonetelAgent.pauseRecording(body);
|
||||||
return result;
|
return result;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const message =
|
const message = error.response?.data?.message ?? error.message ?? 'Recording control failed';
|
||||||
error.response?.data?.message ??
|
|
||||||
error.message ??
|
|
||||||
'Recording control failed';
|
|
||||||
throw new HttpException(message, error.response?.status ?? 502);
|
throw new HttpException(message, error.response?.status ?? 502);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -309,9 +262,7 @@ export class OzonetelAgentController {
|
|||||||
@Query('callType') callType?: string,
|
@Query('callType') callType?: string,
|
||||||
) {
|
) {
|
||||||
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
||||||
this.logger.log(
|
this.logger.log(`Call history: date=${targetDate} status=${status ?? 'all'} type=${callType ?? 'all'}`);
|
||||||
`Call history: date=${targetDate} status=${status ?? 'all'} type=${callType ?? 'all'}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await this.ozonetelAgent.fetchCDR({
|
const result = await this.ozonetelAgent.fetchCDR({
|
||||||
date: targetDate,
|
date: targetDate,
|
||||||
@@ -324,9 +275,7 @@ export class OzonetelAgentController {
|
|||||||
@Get('performance')
|
@Get('performance')
|
||||||
async performance(@Query('date') date?: string) {
|
async performance(@Query('date') date?: string) {
|
||||||
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
||||||
this.logger.log(
|
this.logger.log(`Performance: date=${targetDate} agent=${this.defaultAgentId}`);
|
||||||
`Performance: date=${targetDate} agent=${this.defaultAgentId}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [cdr, summary, aht] = await Promise.all([
|
const [cdr, summary, aht] = await Promise.all([
|
||||||
this.ozonetelAgent.fetchCDR({ date: targetDate }),
|
this.ozonetelAgent.fetchCDR({ date: targetDate }),
|
||||||
@@ -336,13 +285,9 @@ export class OzonetelAgentController {
|
|||||||
|
|
||||||
const totalCalls = cdr.length;
|
const totalCalls = cdr.length;
|
||||||
const inbound = cdr.filter((c: any) => c.Type === 'InBound').length;
|
const inbound = cdr.filter((c: any) => c.Type === 'InBound').length;
|
||||||
const outbound = cdr.filter(
|
const outbound = cdr.filter((c: any) => c.Type === 'Manual' || c.Type === 'Progressive').length;
|
||||||
(c: any) => c.Type === 'Manual' || c.Type === 'Progressive',
|
|
||||||
).length;
|
|
||||||
const answered = cdr.filter((c: any) => c.Status === 'Answered').length;
|
const answered = cdr.filter((c: any) => c.Status === 'Answered').length;
|
||||||
const missed = cdr.filter(
|
const missed = cdr.filter((c: any) => c.Status === 'Unanswered' || c.Status === 'NotAnswered').length;
|
||||||
(c: any) => c.Status === 'Unanswered' || c.Status === 'NotAnswered',
|
|
||||||
).length;
|
|
||||||
|
|
||||||
const talkTimes = cdr
|
const talkTimes = cdr
|
||||||
.filter((c: any) => c.TalkTime && c.TalkTime !== '00:00:00')
|
.filter((c: any) => c.TalkTime && c.TalkTime !== '00:00:00')
|
||||||
@@ -350,12 +295,8 @@ export class OzonetelAgentController {
|
|||||||
const parts = c.TalkTime.split(':').map(Number);
|
const parts = c.TalkTime.split(':').map(Number);
|
||||||
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||||
});
|
});
|
||||||
const avgTalkTimeSec =
|
const avgTalkTimeSec = talkTimes.length > 0
|
||||||
talkTimes.length > 0
|
? Math.round(talkTimes.reduce((a: number, b: number) => a + b, 0) / talkTimes.length)
|
||||||
? Math.round(
|
|
||||||
talkTimes.reduce((a: number, b: number) => a + b, 0) /
|
|
||||||
talkTimes.length,
|
|
||||||
)
|
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const dispositions: Record<string, number> = {};
|
const dispositions: Record<string, number> = {};
|
||||||
@@ -373,10 +314,7 @@ export class OzonetelAgentController {
|
|||||||
calls: { total: totalCalls, inbound, outbound, answered, missed },
|
calls: { total: totalCalls, inbound, outbound, answered, missed },
|
||||||
avgTalkTimeSec,
|
avgTalkTimeSec,
|
||||||
avgHandlingTime: aht,
|
avgHandlingTime: aht,
|
||||||
conversionRate:
|
conversionRate: totalCalls > 0 ? Math.round((appointmentsBooked / totalCalls) * 100) : 0,
|
||||||
totalCalls > 0
|
|
||||||
? Math.round((appointmentsBooked / totalCalls) * 100)
|
|
||||||
: 0,
|
|
||||||
appointmentsBooked,
|
appointmentsBooked,
|
||||||
timeUtilization: summary,
|
timeUtilization: summary,
|
||||||
dispositions,
|
dispositions,
|
||||||
@@ -386,12 +324,12 @@ export class OzonetelAgentController {
|
|||||||
private mapToOzonetelDisposition(disposition: string): string {
|
private mapToOzonetelDisposition(disposition: string): string {
|
||||||
// Campaign only has 'General Enquiry' configured currently
|
// Campaign only has 'General Enquiry' configured currently
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
APPOINTMENT_BOOKED: 'General Enquiry',
|
'APPOINTMENT_BOOKED': 'General Enquiry',
|
||||||
FOLLOW_UP_SCHEDULED: 'General Enquiry',
|
'FOLLOW_UP_SCHEDULED': 'General Enquiry',
|
||||||
INFO_PROVIDED: 'General Enquiry',
|
'INFO_PROVIDED': 'General Enquiry',
|
||||||
NO_ANSWER: 'General Enquiry',
|
'NO_ANSWER': 'General Enquiry',
|
||||||
WRONG_NUMBER: 'General Enquiry',
|
'WRONG_NUMBER': 'General Enquiry',
|
||||||
CALLBACK_REQUESTED: 'General Enquiry',
|
'CALLBACK_REQUESTED': 'General Enquiry',
|
||||||
};
|
};
|
||||||
return map[disposition] ?? 'General Enquiry';
|
return map[disposition] ?? 'General Enquiry';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export type CreateCallInput = {
|
|||||||
disposition?: string;
|
disposition?: string;
|
||||||
callNotes?: string;
|
callNotes?: string;
|
||||||
leadId?: string;
|
leadId?: string;
|
||||||
|
sla?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreateLeadActivityInput = {
|
export type CreateLeadActivityInput = {
|
||||||
|
|||||||
53
src/recordings/recordings.controller.ts
Normal file
53
src/recordings/recordings.controller.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Controller, Post, Body, Logger, HttpException } from '@nestjs/common';
|
||||||
|
import { RecordingsService } from './recordings.service';
|
||||||
|
import { SessionService } from '../auth/session.service';
|
||||||
|
|
||||||
|
const CACHE_TTL = 7 * 24 * 3600; // 7 days
|
||||||
|
|
||||||
|
@Controller('api/recordings')
|
||||||
|
export class RecordingsController {
|
||||||
|
private readonly logger = new Logger(RecordingsController.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly recordings: RecordingsService,
|
||||||
|
private readonly session: SessionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post('analyze')
|
||||||
|
async analyze(@Body() body: { recordingUrl: string; callId?: string }) {
|
||||||
|
if (!body.recordingUrl) {
|
||||||
|
throw new HttpException('recordingUrl required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = body.callId ? `call:analysis:${body.callId}` : null;
|
||||||
|
|
||||||
|
// Check Redis cache first
|
||||||
|
if (cacheKey) {
|
||||||
|
try {
|
||||||
|
const cached = await this.session.getCache(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
this.logger.log(`[RECORDING] Cache hit: ${cacheKey}`);
|
||||||
|
return JSON.parse(cached);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[RECORDING] Cache miss — analyzing: ${body.recordingUrl} callId=${body.callId ?? 'none'}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const analysis = await this.recordings.analyzeRecording(body.recordingUrl);
|
||||||
|
this.logger.log(`[RECORDING] Analysis complete: ${analysis.transcript.length} utterances, sentiment=${analysis.sentiment}`);
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
if (cacheKey) {
|
||||||
|
this.session.setCache(cacheKey, JSON.stringify(analysis), CACHE_TTL)
|
||||||
|
.catch(err => this.logger.warn(`[RECORDING] Cache write failed: ${err}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
return analysis;
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`[RECORDING] Analysis failed: ${error.message}`);
|
||||||
|
throw new HttpException(error.message ?? 'Analysis failed', 502);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/recordings/recordings.module.ts
Normal file
11
src/recordings/recordings.module.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { RecordingsController } from './recordings.controller';
|
||||||
|
import { RecordingsService } from './recordings.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule],
|
||||||
|
controllers: [RecordingsController],
|
||||||
|
providers: [RecordingsService],
|
||||||
|
})
|
||||||
|
export class RecordingsModule {}
|
||||||
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';
|
import { SupervisorService } from './supervisor.service';
|
||||||
|
|
||||||
@Controller('api/supervisor')
|
@Controller('api/supervisor')
|
||||||
@@ -22,9 +23,7 @@ export class SupervisorController {
|
|||||||
@Post('call-event')
|
@Post('call-event')
|
||||||
handleCallEvent(@Body() body: any) {
|
handleCallEvent(@Body() body: any) {
|
||||||
const event = body.data ?? body;
|
const event = body.data ?? body;
|
||||||
this.logger.log(
|
this.logger.log(`[CALL-EVENT] ${JSON.stringify(event)}`);
|
||||||
`Call event: ${event.action} ucid=${event.ucid ?? event.monitorUCID} agent=${event.agent_id ?? event.agentID}`,
|
|
||||||
);
|
|
||||||
this.supervisor.handleCallEvent(event);
|
this.supervisor.handleCallEvent(event);
|
||||||
return { received: true };
|
return { received: true };
|
||||||
}
|
}
|
||||||
@@ -32,10 +31,25 @@ export class SupervisorController {
|
|||||||
@Post('agent-event')
|
@Post('agent-event')
|
||||||
handleAgentEvent(@Body() body: any) {
|
handleAgentEvent(@Body() body: any) {
|
||||||
const event = body.data ?? body;
|
const event = body.data ?? body;
|
||||||
this.logger.log(
|
this.logger.log(`[AGENT-EVENT] ${JSON.stringify(event)}`);
|
||||||
`Agent event: ${event.action} agent=${event.agentId ?? event.agent_id}`,
|
|
||||||
);
|
|
||||||
this.supervisor.handleAgentEvent(event);
|
this.supervisor.handleAgentEvent(event);
|
||||||
return { received: true };
|
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],
|
imports: [PlatformModule, OzonetelAgentModule],
|
||||||
controllers: [SupervisorController],
|
controllers: [SupervisorController],
|
||||||
providers: [SupervisorService],
|
providers: [SupervisorService],
|
||||||
|
exports: [SupervisorService],
|
||||||
})
|
})
|
||||||
export class SupervisorModule {}
|
export class SupervisorModule {}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
||||||
|
|
||||||
@@ -12,10 +13,19 @@ type ActiveCall = {
|
|||||||
status: 'active' | 'on-hold';
|
status: 'active' | 'on-hold';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AgentOzonetelState = 'ready' | 'break' | 'training' | 'calling' | 'in-call' | 'acw' | 'offline';
|
||||||
|
|
||||||
|
type AgentStateEntry = {
|
||||||
|
state: AgentOzonetelState;
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SupervisorService implements OnModuleInit {
|
export class SupervisorService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(SupervisorService.name);
|
private readonly logger = new Logger(SupervisorService.name);
|
||||||
private readonly activeCalls = new Map<string, ActiveCall>();
|
private readonly activeCalls = new Map<string, ActiveCall>();
|
||||||
|
private readonly agentStates = new Map<string, AgentStateEntry>();
|
||||||
|
readonly agentStateSubject = new Subject<{ agentId: string; state: AgentOzonetelState; timestamp: string }>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private platform: PlatformGraphqlService,
|
private platform: PlatformGraphqlService,
|
||||||
@@ -33,19 +43,14 @@ export class SupervisorService implements OnModuleInit {
|
|||||||
const agentId = event.agent_id ?? event.agentID;
|
const agentId = event.agent_id ?? event.agentID;
|
||||||
const callerNumber = event.caller_id ?? event.callerID;
|
const callerNumber = event.caller_id ?? event.callerID;
|
||||||
const callType = event.call_type ?? event.Type;
|
const callType = event.call_type ?? event.Type;
|
||||||
const eventTime =
|
const eventTime = event.event_time ?? event.eventTime ?? new Date().toISOString();
|
||||||
event.event_time ?? event.eventTime ?? new Date().toISOString();
|
|
||||||
|
|
||||||
if (!ucid) return;
|
if (!ucid) return;
|
||||||
|
|
||||||
if (action === 'Answered' || action === 'Calling') {
|
if (action === 'Answered' || action === 'Calling') {
|
||||||
this.activeCalls.set(ucid, {
|
this.activeCalls.set(ucid, {
|
||||||
ucid,
|
ucid, agentId, callerNumber,
|
||||||
agentId,
|
callType, startTime: eventTime, status: 'active',
|
||||||
callerNumber,
|
|
||||||
callType,
|
|
||||||
startTime: eventTime,
|
|
||||||
status: 'active',
|
|
||||||
});
|
});
|
||||||
this.logger.log(`Active call: ${agentId} ↔ ${callerNumber} (${ucid})`);
|
this.logger.log(`Active call: ${agentId} ↔ ${callerNumber} (${ucid})`);
|
||||||
} else if (action === 'Disconnect') {
|
} else if (action === 'Disconnect') {
|
||||||
@@ -55,9 +60,47 @@ export class SupervisorService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleAgentEvent(event: any) {
|
handleAgentEvent(event: any) {
|
||||||
this.logger.log(
|
const agentId = event.agentId ?? event.agent_id ?? 'unknown';
|
||||||
`Agent event: ${event.agentId ?? event.agent_id} → ${event.action}`,
|
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[] {
|
getActiveCalls(): ActiveCall[] {
|
||||||
@@ -79,15 +122,10 @@ export class SupervisorService implements OnModuleInit {
|
|||||||
agents.map(async (agent: any) => {
|
agents.map(async (agent: any) => {
|
||||||
if (!agent.ozonetelagentid) return { ...agent, timeBreakdown: null };
|
if (!agent.ozonetelagentid) return { ...agent, timeBreakdown: null };
|
||||||
try {
|
try {
|
||||||
const summary = await this.ozonetel.getAgentSummary(
|
const summary = await this.ozonetel.getAgentSummary(agent.ozonetelagentid, date);
|
||||||
agent.ozonetelagentid,
|
|
||||||
date,
|
|
||||||
);
|
|
||||||
return { ...agent, timeBreakdown: summary };
|
return { ...agent, timeBreakdown: summary };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(
|
this.logger.warn(`Failed to get summary for ${agent.ozonetelagentid}: ${err}`);
|
||||||
`Failed to get summary for ${agent.ozonetelagentid}: ${err}`,
|
|
||||||
);
|
|
||||||
return { ...agent, timeBreakdown: null };
|
return { ...agent, timeBreakdown: null };
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -2,6 +2,16 @@ import { Controller, Post, Body, Headers, Logger } from '@nestjs/common';
|
|||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
import { ConfigService } from '@nestjs/config';
|
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')
|
@Controller('webhooks/ozonetel')
|
||||||
export class MissedCallWebhookController {
|
export class MissedCallWebhookController {
|
||||||
private readonly logger = new Logger(MissedCallWebhookController.name);
|
private readonly logger = new Logger(MissedCallWebhookController.name);
|
||||||
@@ -24,9 +34,7 @@ export class MissedCallWebhookController {
|
|||||||
payload = body;
|
payload = body;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(`Call webhook: ${payload.CallerID} | ${payload.Status} | ${payload.Type}`);
|
||||||
`Call webhook: ${payload.CallerID} | ${payload.Status} | ${payload.Type}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const callerPhone = (payload.CallerID ?? '').replace(/^\+?91/, '');
|
const callerPhone = (payload.CallerID ?? '').replace(/^\+?91/, '');
|
||||||
const status = payload.Status; // NotAnswered, Answered, Abandoned
|
const status = payload.Status; // NotAnswered, Answered, Abandoned
|
||||||
@@ -52,16 +60,13 @@ export class MissedCallWebhookController {
|
|||||||
// Use API key auth for server-to-server writes
|
// Use API key auth for server-to-server writes
|
||||||
const authHeader = this.apiKey ? `Bearer ${this.apiKey}` : '';
|
const authHeader = this.apiKey ? `Bearer ${this.apiKey}` : '';
|
||||||
if (!authHeader) {
|
if (!authHeader) {
|
||||||
this.logger.warn(
|
this.logger.warn('No PLATFORM_API_KEY configured — cannot write call records');
|
||||||
'No PLATFORM_API_KEY configured — cannot write call records',
|
|
||||||
);
|
|
||||||
return { received: true, processed: false };
|
return { received: true, processed: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Create call record
|
// Step 1: Create call record
|
||||||
const callId = await this.createCall(
|
const callId = await this.createCall({
|
||||||
{
|
|
||||||
callerPhone,
|
callerPhone,
|
||||||
direction,
|
direction,
|
||||||
callStatus,
|
callStatus,
|
||||||
@@ -72,9 +77,7 @@ export class MissedCallWebhookController {
|
|||||||
recordingUrl,
|
recordingUrl,
|
||||||
disposition,
|
disposition,
|
||||||
ucid,
|
ucid,
|
||||||
},
|
}, authHeader);
|
||||||
authHeader,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(`Created call record: ${callId} (${callStatus})`);
|
this.logger.log(`Created call record: ${callId} (${callStatus})`);
|
||||||
|
|
||||||
@@ -86,65 +89,40 @@ export class MissedCallWebhookController {
|
|||||||
await this.updateCall(callId, { leadId: lead.id }, authHeader);
|
await this.updateCall(callId, { leadId: lead.id }, authHeader);
|
||||||
|
|
||||||
// Step 4: Create lead activity
|
// Step 4: Create lead activity
|
||||||
const summary =
|
const summary = callStatus === 'MISSED'
|
||||||
callStatus === 'MISSED'
|
|
||||||
? `Missed inbound call from ${callerPhone} (${duration}s, ${hangupBy ?? 'unknown'})`
|
? `Missed inbound call from ${callerPhone} (${duration}s, ${hangupBy ?? 'unknown'})`
|
||||||
: `Inbound call from ${callerPhone} — ${duration}s, ${disposition || 'no disposition'}`;
|
: `Inbound call from ${callerPhone} — ${duration}s, ${disposition || 'no disposition'}`;
|
||||||
|
|
||||||
await this.createLeadActivity(
|
await this.createLeadActivity({
|
||||||
{
|
|
||||||
leadId: lead.id,
|
leadId: lead.id,
|
||||||
activityType:
|
activityType: callStatus === 'MISSED' ? 'CALL_RECEIVED' : 'CALL_RECEIVED',
|
||||||
callStatus === 'MISSED' ? 'CALL_RECEIVED' : 'CALL_RECEIVED',
|
|
||||||
summary,
|
summary,
|
||||||
channel: 'PHONE',
|
channel: 'PHONE',
|
||||||
performedBy: agentName ?? 'System',
|
performedBy: agentName ?? 'System',
|
||||||
durationSeconds: duration,
|
durationSeconds: duration,
|
||||||
outcome: callStatus === 'MISSED' ? 'NO_ANSWER' : 'SUCCESSFUL',
|
outcome: callStatus === 'MISSED' ? 'NO_ANSWER' : 'SUCCESSFUL',
|
||||||
},
|
}, authHeader);
|
||||||
authHeader,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Step 5: Update lead contact timestamps
|
// Step 5: Update lead contact timestamps
|
||||||
await this.updateLead(
|
await this.updateLead(lead.id, {
|
||||||
lead.id,
|
lastContacted: startTime ? new Date(startTime).toISOString() : new Date().toISOString(),
|
||||||
{
|
|
||||||
lastContacted: startTime
|
|
||||||
? new Date(startTime).toISOString()
|
|
||||||
: new Date().toISOString(),
|
|
||||||
contactAttempts: (lead.contactAttempts ?? 0) + 1,
|
contactAttempts: (lead.contactAttempts ?? 0) + 1,
|
||||||
},
|
}, authHeader);
|
||||||
authHeader,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(`Linked call to lead ${lead.id} (${lead.name}), activity created`);
|
||||||
`Linked call to lead ${lead.id} (${lead.name}), activity created`,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
this.logger.log(
|
this.logger.log(`No matching lead for ${callerPhone} — call record created without lead link`);
|
||||||
`No matching lead for ${callerPhone} — call record created without lead link`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { received: true, processed: true, callId, leadId: lead?.id ?? null };
|
||||||
received: true,
|
|
||||||
processed: true,
|
|
||||||
callId,
|
|
||||||
leadId: lead?.id ?? null,
|
|
||||||
};
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const responseData = err?.response?.data
|
const responseData = err?.response?.data ? JSON.stringify(err.response.data) : '';
|
||||||
? JSON.stringify(err.response.data)
|
this.logger.error(`Webhook processing failed: ${err.message} ${responseData}`);
|
||||||
: '';
|
|
||||||
this.logger.error(
|
|
||||||
`Webhook processing failed: ${err.message} ${responseData}`,
|
|
||||||
);
|
|
||||||
return { received: true, processed: false, error: String(err) };
|
return { received: true, processed: false, error: String(err) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createCall(
|
private async createCall(data: {
|
||||||
data: {
|
|
||||||
callerPhone: string;
|
callerPhone: string;
|
||||||
direction: string;
|
direction: string;
|
||||||
callStatus: string;
|
callStatus: string;
|
||||||
@@ -155,17 +133,15 @@ export class MissedCallWebhookController {
|
|||||||
recordingUrl: string | null;
|
recordingUrl: string | null;
|
||||||
disposition: string | null;
|
disposition: string | null;
|
||||||
ucid: string | null;
|
ucid: string | null;
|
||||||
},
|
}, authHeader: string): Promise<string> {
|
||||||
authHeader: string,
|
|
||||||
): Promise<string> {
|
|
||||||
const callData: Record<string, any> = {
|
const callData: Record<string, any> = {
|
||||||
name: `${data.direction === 'INBOUND' ? 'Inbound' : 'Outbound'} — ${data.callerPhone}`,
|
name: `${data.direction === 'INBOUND' ? 'Inbound' : 'Outbound'} — ${data.callerPhone}`,
|
||||||
direction: data.direction,
|
direction: data.direction,
|
||||||
callStatus: data.callStatus,
|
callStatus: data.callStatus,
|
||||||
callerNumber: { primaryPhoneNumber: `+91${data.callerPhone}` },
|
callerNumber: { primaryPhoneNumber: `+91${data.callerPhone}` },
|
||||||
agentName: data.agentName,
|
agentName: data.agentName,
|
||||||
startedAt: data.startTime ? new Date(data.startTime).toISOString() : null,
|
startedAt: istToUtc(data.startTime),
|
||||||
endedAt: data.endTime ? new Date(data.endTime).toISOString() : null,
|
endedAt: istToUtc(data.endTime),
|
||||||
durationSec: data.duration,
|
durationSec: data.duration,
|
||||||
disposition: this.mapDisposition(data.disposition),
|
disposition: this.mapDisposition(data.disposition),
|
||||||
};
|
};
|
||||||
@@ -175,10 +151,7 @@ export class MissedCallWebhookController {
|
|||||||
callData.missedcallcount = 1;
|
callData.missedcallcount = 1;
|
||||||
}
|
}
|
||||||
if (data.recordingUrl) {
|
if (data.recordingUrl) {
|
||||||
callData.recording = {
|
callData.recording = { primaryLinkUrl: data.recordingUrl, primaryLinkLabel: 'Recording' };
|
||||||
primaryLinkUrl: data.recordingUrl,
|
|
||||||
primaryLinkLabel: 'Recording',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.platform.queryWithAuth<any>(
|
const result = await this.platform.queryWithAuth<any>(
|
||||||
@@ -189,10 +162,7 @@ export class MissedCallWebhookController {
|
|||||||
return result.createCall.id;
|
return result.createCall.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findLeadByPhone(
|
private async findLeadByPhone(phone: string, authHeader: string): Promise<{ id: string; name: string; contactAttempts: number } | null> {
|
||||||
phone: string,
|
|
||||||
authHeader: string,
|
|
||||||
): Promise<{ id: string; name: string; contactAttempts: number } | null> {
|
|
||||||
const result = await this.platform.queryWithAuth<any>(
|
const result = await this.platform.queryWithAuth<any>(
|
||||||
`{ leads(first: 50) { edges { node { id name contactPhone { primaryPhoneNumber } contactAttempts lastContacted } } } }`,
|
`{ leads(first: 50) { edges { node { id name contactPhone { primaryPhoneNumber } contactAttempts lastContacted } } } }`,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -201,22 +171,13 @@ export class MissedCallWebhookController {
|
|||||||
const leads = result.leads.edges.map((e: any) => e.node);
|
const leads = result.leads.edges.map((e: any) => e.node);
|
||||||
const cleanPhone = phone.replace(/\D/g, '');
|
const cleanPhone = phone.replace(/\D/g, '');
|
||||||
|
|
||||||
return (
|
return leads.find((l: any) => {
|
||||||
leads.find((l: any) => {
|
const lp = (l.contactPhone?.primaryPhoneNumber ?? '').replace(/\D/g, '');
|
||||||
const lp = (l.contactPhone?.primaryPhoneNumber ?? '').replace(
|
|
||||||
/\D/g,
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
return lp.endsWith(cleanPhone) || cleanPhone.endsWith(lp);
|
return lp.endsWith(cleanPhone) || cleanPhone.endsWith(lp);
|
||||||
}) ?? null
|
}) ?? null;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateCall(
|
private async updateCall(callId: string, data: Record<string, any>, authHeader: string): Promise<void> {
|
||||||
callId: string,
|
|
||||||
data: Record<string, any>,
|
|
||||||
authHeader: string,
|
|
||||||
): Promise<void> {
|
|
||||||
await this.platform.queryWithAuth<any>(
|
await this.platform.queryWithAuth<any>(
|
||||||
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
||||||
{ id: callId, data },
|
{ id: callId, data },
|
||||||
@@ -224,8 +185,7 @@ export class MissedCallWebhookController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createLeadActivity(
|
private async createLeadActivity(data: {
|
||||||
data: {
|
|
||||||
leadId: string;
|
leadId: string;
|
||||||
activityType: string;
|
activityType: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
@@ -233,9 +193,7 @@ export class MissedCallWebhookController {
|
|||||||
performedBy: string;
|
performedBy: string;
|
||||||
durationSeconds: number;
|
durationSeconds: number;
|
||||||
outcome: string;
|
outcome: string;
|
||||||
},
|
}, authHeader: string): Promise<void> {
|
||||||
authHeader: string,
|
|
||||||
): Promise<void> {
|
|
||||||
await this.platform.queryWithAuth<any>(
|
await this.platform.queryWithAuth<any>(
|
||||||
`mutation($data: LeadActivityCreateInput!) { createLeadActivity(data: $data) { id } }`,
|
`mutation($data: LeadActivityCreateInput!) { createLeadActivity(data: $data) { id } }`,
|
||||||
{
|
{
|
||||||
@@ -255,11 +213,7 @@ export class MissedCallWebhookController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateLead(
|
private async updateLead(leadId: string, data: Record<string, any>, authHeader: string): Promise<void> {
|
||||||
leadId: string,
|
|
||||||
data: Record<string, any>,
|
|
||||||
authHeader: string,
|
|
||||||
): Promise<void> {
|
|
||||||
await this.platform.queryWithAuth<any>(
|
await this.platform.queryWithAuth<any>(
|
||||||
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
||||||
{ id: leadId, data },
|
{ id: leadId, data },
|
||||||
|
|||||||
@@ -3,15 +3,22 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
||||||
|
|
||||||
|
// 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
|
// Normalize phone to +91XXXXXXXXXX format
|
||||||
export function normalizePhone(raw: string): string {
|
export function normalizePhone(raw: string): string {
|
||||||
let digits = raw.replace(/[^0-9]/g, '');
|
let digits = raw.replace(/[^0-9]/g, '');
|
||||||
// Strip leading country code variations: 0091, 91, 0
|
// Strip leading country code variations: 0091, 91, 0
|
||||||
if (digits.startsWith('0091')) digits = digits.slice(4);
|
if (digits.startsWith('0091')) digits = digits.slice(4);
|
||||||
else if (digits.startsWith('91') && digits.length > 10)
|
else if (digits.startsWith('91') && digits.length > 10) digits = digits.slice(2);
|
||||||
digits = digits.slice(2);
|
else if (digits.startsWith('0') && digits.length > 10) digits = digits.slice(1);
|
||||||
else if (digits.startsWith('0') && digits.length > 10)
|
|
||||||
digits = digits.slice(1);
|
|
||||||
return `+91${digits.slice(-10)}`;
|
return `+91${digits.slice(-10)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,23 +34,12 @@ export class MissedQueueService implements OnModuleInit {
|
|||||||
private readonly platform: PlatformGraphqlService,
|
private readonly platform: PlatformGraphqlService,
|
||||||
private readonly ozonetel: OzonetelAgentService,
|
private readonly ozonetel: OzonetelAgentService,
|
||||||
) {
|
) {
|
||||||
this.pollIntervalMs = this.config.get<number>(
|
this.pollIntervalMs = this.config.get<number>('missedQueue.pollIntervalMs', 30000);
|
||||||
'missedQueue.pollIntervalMs',
|
|
||||||
30000,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
this.logger.log(
|
this.logger.log(`Starting missed call ingestion polling every ${this.pollIntervalMs}ms`);
|
||||||
`Starting missed call ingestion polling every ${this.pollIntervalMs}ms`,
|
setInterval(() => this.ingest().catch(err => this.logger.error('Ingestion failed', err)), this.pollIntervalMs);
|
||||||
);
|
|
||||||
setInterval(
|
|
||||||
() =>
|
|
||||||
this.ingest().catch((err) =>
|
|
||||||
this.logger.error('Ingestion failed', err),
|
|
||||||
),
|
|
||||||
this.pollIntervalMs,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async ingest(): Promise<{ created: number; updated: number }> {
|
async ingest(): Promise<{ created: number; updated: number }> {
|
||||||
@@ -57,10 +53,7 @@ export class MissedQueueService implements OnModuleInit {
|
|||||||
|
|
||||||
let abandonCalls: any[];
|
let abandonCalls: any[];
|
||||||
try {
|
try {
|
||||||
abandonCalls = await this.ozonetel.getAbandonCalls({
|
abandonCalls = await this.ozonetel.getAbandonCalls({ fromTime: toHHMMSS(fiveMinAgo), toTime: toHHMMSS(now) });
|
||||||
fromTime: toHHMMSS(fiveMinAgo),
|
|
||||||
toTime: toHHMMSS(now),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Failed to fetch abandon calls: ${err}`);
|
this.logger.warn(`Failed to fetch abandon calls: ${err}`);
|
||||||
return { created: 0, updated: 0 };
|
return { created: 0, updated: 0 };
|
||||||
@@ -77,9 +70,31 @@ export class MissedQueueService implements OnModuleInit {
|
|||||||
if (!phone || phone.length < 13) continue;
|
if (!phone || phone.length < 13) continue;
|
||||||
|
|
||||||
const did = call.did || '';
|
const did = call.did || '';
|
||||||
const callTime = call.callTime || new Date().toISOString();
|
const callTime = istToUtc(call.callTime) ?? new Date().toISOString();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Look up lead by phone number — strip +91 prefix for flexible matching
|
||||||
|
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>(
|
const existing = await this.platform.query<any>(
|
||||||
`{ calls(first: 1, filter: {
|
`{ calls(first: 1, filter: {
|
||||||
callbackstatus: { eq: PENDING_CALLBACK },
|
callbackstatus: { eq: PENDING_CALLBACK },
|
||||||
@@ -91,29 +106,35 @@ export class MissedQueueService implements OnModuleInit {
|
|||||||
|
|
||||||
if (existingNode) {
|
if (existingNode) {
|
||||||
const newCount = (existingNode.missedcallcount || 1) + 1;
|
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>(
|
await this.platform.query<any>(
|
||||||
`mutation { updateCall(id: "${existingNode.id}", data: {
|
`mutation { updateCall(id: "${existingNode.id}", data: { ${updateParts.join(', ')} }) { id } }`,
|
||||||
missedcallcount: ${newCount},
|
|
||||||
startedAt: "${callTime}",
|
|
||||||
callsourcenumber: "${did}"
|
|
||||||
}) { id } }`,
|
|
||||||
);
|
);
|
||||||
updated++;
|
updated++;
|
||||||
this.logger.log(`Dedup missed call ${phone}: count now ${newCount}`);
|
this.logger.log(`Dedup missed call ${phone}: count now ${newCount}${leadName ? ` (${leadName})` : ''}`);
|
||||||
} else {
|
} 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>(
|
await this.platform.query<any>(
|
||||||
`mutation { createCall(data: {
|
`mutation { createCall(data: { ${dataParts.join(', ')} }) { id } }`,
|
||||||
callStatus: MISSED,
|
|
||||||
direction: INBOUND,
|
|
||||||
callerNumber: { primaryPhoneNumber: "${phone}", primaryPhoneCallingCode: "+91" },
|
|
||||||
callsourcenumber: "${did}",
|
|
||||||
callbackstatus: PENDING_CALLBACK,
|
|
||||||
missedcallcount: 1,
|
|
||||||
startedAt: "${callTime}"
|
|
||||||
}) { id } }`,
|
|
||||||
);
|
);
|
||||||
created++;
|
created++;
|
||||||
this.logger.log(`Created missed call record for ${phone}`);
|
this.logger.log(`Created missed call record for ${phone}${leadName ? ` → ${leadName}` : ''}`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Failed to process abandon call ${ucid}: ${err}`);
|
this.logger.warn(`Failed to process abandon call ${ucid}: ${err}`);
|
||||||
@@ -124,11 +145,10 @@ export class MissedQueueService implements OnModuleInit {
|
|||||||
if (this.processedUcids.size > 500) {
|
if (this.processedUcids.size > 500) {
|
||||||
const arr = Array.from(this.processedUcids);
|
const arr = Array.from(this.processedUcids);
|
||||||
this.processedUcids.clear();
|
this.processedUcids.clear();
|
||||||
arr.slice(-200).forEach((u) => this.processedUcids.add(u));
|
arr.slice(-200).forEach(u => this.processedUcids.add(u));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (created || updated)
|
if (created || updated) this.logger.log(`Ingestion: ${created} created, ${updated} updated`);
|
||||||
this.logger.log(`Ingestion: ${created} created, ${updated} updated`);
|
|
||||||
return { created, updated };
|
return { created, updated };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,22 +203,10 @@ export class MissedQueueService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateStatus(
|
async updateStatus(callId: string, status: string, authHeader: string): Promise<any> {
|
||||||
callId: string,
|
const validStatuses = ['PENDING_CALLBACK', 'CALLBACK_ATTEMPTED', 'CALLBACK_COMPLETED', 'INVALID', 'WRONG_NUMBER'];
|
||||||
status: string,
|
|
||||||
authHeader: string,
|
|
||||||
): Promise<any> {
|
|
||||||
const validStatuses = [
|
|
||||||
'PENDING_CALLBACK',
|
|
||||||
'CALLBACK_ATTEMPTED',
|
|
||||||
'CALLBACK_COMPLETED',
|
|
||||||
'INVALID',
|
|
||||||
'WRONG_NUMBER',
|
|
||||||
];
|
|
||||||
if (!validStatuses.includes(status)) {
|
if (!validStatuses.includes(status)) {
|
||||||
throw new Error(
|
throw new Error(`Invalid status: ${status}. Must be one of: ${validStatuses.join(', ')}`);
|
||||||
`Invalid status: ${status}. Must be one of: ${validStatuses.join(', ')}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataParts: string[] = [`callbackstatus: ${status}`];
|
const dataParts: string[] = [`callbackstatus: ${status}`];
|
||||||
@@ -213,10 +221,7 @@ export class MissedQueueService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMissedQueue(
|
async getMissedQueue(agentName: string, authHeader: string): Promise<{
|
||||||
agentName: string,
|
|
||||||
authHeader: string,
|
|
||||||
): Promise<{
|
|
||||||
pending: any[];
|
pending: any[];
|
||||||
attempted: any[];
|
attempted: any[];
|
||||||
completed: any[];
|
completed: any[];
|
||||||
@@ -234,37 +239,15 @@ export class MissedQueueService implements OnModuleInit {
|
|||||||
}, orderBy: [{ startedAt: AscNullsLast }]) { edges { node { ${fields} } } } }`;
|
}, orderBy: [{ startedAt: AscNullsLast }]) { edges { node { ${fields} } } } }`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [pending, attempted, completed, invalid, wrongNumber] =
|
const [pending, attempted, completed, invalid, wrongNumber] = await Promise.all([
|
||||||
await Promise.all([
|
this.platform.queryWithAuth<any>(buildQuery('PENDING_CALLBACK'), undefined, authHeader),
|
||||||
this.platform.queryWithAuth<any>(
|
this.platform.queryWithAuth<any>(buildQuery('CALLBACK_ATTEMPTED'), undefined, authHeader),
|
||||||
buildQuery('PENDING_CALLBACK'),
|
this.platform.queryWithAuth<any>(buildQuery('CALLBACK_COMPLETED'), undefined, authHeader),
|
||||||
undefined,
|
this.platform.queryWithAuth<any>(buildQuery('INVALID'), undefined, authHeader),
|
||||||
authHeader,
|
this.platform.queryWithAuth<any>(buildQuery('WRONG_NUMBER'), undefined, authHeader),
|
||||||
),
|
|
||||||
this.platform.queryWithAuth<any>(
|
|
||||||
buildQuery('CALLBACK_ATTEMPTED'),
|
|
||||||
undefined,
|
|
||||||
authHeader,
|
|
||||||
),
|
|
||||||
this.platform.queryWithAuth<any>(
|
|
||||||
buildQuery('CALLBACK_COMPLETED'),
|
|
||||||
undefined,
|
|
||||||
authHeader,
|
|
||||||
),
|
|
||||||
this.platform.queryWithAuth<any>(
|
|
||||||
buildQuery('INVALID'),
|
|
||||||
undefined,
|
|
||||||
authHeader,
|
|
||||||
),
|
|
||||||
this.platform.queryWithAuth<any>(
|
|
||||||
buildQuery('WRONG_NUMBER'),
|
|
||||||
undefined,
|
|
||||||
authHeader,
|
|
||||||
),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const extract = (r: any) =>
|
const extract = (r: any) => r?.calls?.edges?.map((e: any) => e.node) ?? [];
|
||||||
r?.calls?.edges?.map((e: any) => e.node) ?? [];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pending: extract(pending),
|
pending: extract(pending),
|
||||||
|
|||||||
@@ -1,16 +1,8 @@
|
|||||||
import {
|
import { Controller, Get, Patch, Headers, Param, Body, HttpException, Logger } from '@nestjs/common';
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
Patch,
|
|
||||||
Headers,
|
|
||||||
Param,
|
|
||||||
Body,
|
|
||||||
HttpException,
|
|
||||||
Logger,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
import { WorklistService } from './worklist.service';
|
import { WorklistService } from './worklist.service';
|
||||||
import { MissedQueueService } from './missed-queue.service';
|
import { MissedQueueService } from './missed-queue.service';
|
||||||
|
import { SessionService } from '../auth/session.service';
|
||||||
|
|
||||||
@Controller('api/worklist')
|
@Controller('api/worklist')
|
||||||
export class WorklistController {
|
export class WorklistController {
|
||||||
@@ -20,6 +12,7 @@ export class WorklistController {
|
|||||||
private readonly worklist: WorklistService,
|
private readonly worklist: WorklistService,
|
||||||
private readonly missedQueue: MissedQueueService,
|
private readonly missedQueue: MissedQueueService,
|
||||||
private readonly platform: PlatformGraphqlService,
|
private readonly platform: PlatformGraphqlService,
|
||||||
|
private readonly session: SessionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -36,8 +29,7 @@ export class WorklistController {
|
|||||||
|
|
||||||
@Get('missed-queue')
|
@Get('missed-queue')
|
||||||
async getMissedQueue(@Headers('authorization') authHeader: string) {
|
async getMissedQueue(@Headers('authorization') authHeader: string) {
|
||||||
if (!authHeader)
|
if (!authHeader) throw new HttpException('Authorization header required', 401);
|
||||||
throw new HttpException('Authorization header required', 401);
|
|
||||||
const agentName = await this.resolveAgentName(authHeader);
|
const agentName = await this.resolveAgentName(authHeader);
|
||||||
return this.missedQueue.getMissedQueue(agentName, authHeader);
|
return this.missedQueue.getMissedQueue(agentName, authHeader);
|
||||||
}
|
}
|
||||||
@@ -48,13 +40,18 @@ export class WorklistController {
|
|||||||
@Headers('authorization') authHeader: string,
|
@Headers('authorization') authHeader: string,
|
||||||
@Body() body: { status: string },
|
@Body() body: { status: string },
|
||||||
) {
|
) {
|
||||||
if (!authHeader)
|
if (!authHeader) throw new HttpException('Authorization header required', 401);
|
||||||
throw new HttpException('Authorization header required', 401);
|
|
||||||
if (!body.status) throw new HttpException('status is required', 400);
|
if (!body.status) throw new HttpException('status is required', 400);
|
||||||
return this.missedQueue.updateStatus(id, body.status, authHeader);
|
return this.missedQueue.updateStatus(id, body.status, authHeader);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveAgentName(authHeader: string): Promise<string> {
|
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 {
|
try {
|
||||||
const data = await this.platform.queryWithAuth<any>(
|
const data = await this.platform.queryWithAuth<any>(
|
||||||
`{ currentUser { workspaceMember { name { firstName lastName } } } }`,
|
`{ currentUser { workspaceMember { name { firstName lastName } } } }`,
|
||||||
@@ -65,7 +62,7 @@ export class WorklistController {
|
|||||||
const full = `${name?.firstName ?? ''} ${name?.lastName ?? ''}`.trim();
|
const full = `${name?.firstName ?? ''} ${name?.lastName ?? ''}`.trim();
|
||||||
if (full) return full;
|
if (full) return full;
|
||||||
} catch (err) {
|
} 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);
|
throw new HttpException('Could not determine agent identity', 400);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Module, forwardRef } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { PlatformModule } from '../platform/platform.module';
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { RulesEngineModule } from '../rules-engine/rules-engine.module';
|
||||||
import { WorklistController } from './worklist.controller';
|
import { WorklistController } from './worklist.controller';
|
||||||
import { WorklistService } from './worklist.service';
|
import { WorklistService } from './worklist.service';
|
||||||
import { MissedQueueService } from './missed-queue.service';
|
import { MissedQueueService } from './missed-queue.service';
|
||||||
@@ -8,12 +10,8 @@ import { MissedCallWebhookController } from './missed-call-webhook.controller';
|
|||||||
import { KookooCallbackController } from './kookoo-callback.controller';
|
import { KookooCallbackController } from './kookoo-callback.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule)],
|
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule), forwardRef(() => AuthModule), RulesEngineModule],
|
||||||
controllers: [
|
controllers: [WorklistController, MissedCallWebhookController, KookooCallbackController],
|
||||||
WorklistController,
|
|
||||||
MissedCallWebhookController,
|
|
||||||
KookooCallbackController,
|
|
||||||
],
|
|
||||||
providers: [WorklistService, MissedQueueService],
|
providers: [WorklistService, MissedQueueService],
|
||||||
exports: [MissedQueueService],
|
exports: [MissedQueueService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { WorklistConsumer } from '../rules-engine/consumers/worklist.consumer';
|
||||||
|
|
||||||
export type WorklistResponse = {
|
export type WorklistResponse = {
|
||||||
missedCalls: any[];
|
missedCalls: any[];
|
||||||
@@ -12,31 +13,42 @@ export type WorklistResponse = {
|
|||||||
export class WorklistService {
|
export class WorklistService {
|
||||||
private readonly logger = new Logger(WorklistService.name);
|
private readonly logger = new Logger(WorklistService.name);
|
||||||
|
|
||||||
constructor(private readonly platform: PlatformGraphqlService) {}
|
constructor(
|
||||||
|
private readonly platform: PlatformGraphqlService,
|
||||||
|
private readonly worklistConsumer: WorklistConsumer,
|
||||||
|
) {}
|
||||||
|
|
||||||
async getWorklist(
|
async getWorklist(agentName: string, authHeader: string): Promise<WorklistResponse> {
|
||||||
agentName: string,
|
const [rawMissedCalls, rawFollowUps, rawMarketingLeads] = await Promise.all([
|
||||||
authHeader: string,
|
|
||||||
): Promise<WorklistResponse> {
|
|
||||||
const [missedCalls, followUps, marketingLeads] = await Promise.all([
|
|
||||||
this.getMissedCalls(agentName, authHeader),
|
this.getMissedCalls(agentName, authHeader),
|
||||||
this.getPendingFollowUps(agentName, authHeader),
|
this.getPendingFollowUps(agentName, authHeader),
|
||||||
this.getAssignedLeads(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 {
|
return {
|
||||||
missedCalls,
|
missedCalls,
|
||||||
followUps,
|
followUps,
|
||||||
marketingLeads,
|
marketingLeads,
|
||||||
totalPending:
|
totalPending: missedCalls.length + followUps.length + marketingLeads.length,
|
||||||
missedCalls.length + followUps.length + marketingLeads.length,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getAssignedLeads(
|
private async getAssignedLeads(agentName: string, authHeader: string): Promise<any[]> {
|
||||||
agentName: string,
|
|
||||||
authHeader: string,
|
|
||||||
): Promise<any[]> {
|
|
||||||
try {
|
try {
|
||||||
const data = await this.platform.queryWithAuth<any>(
|
const data = await this.platform.queryWithAuth<any>(
|
||||||
`{ leads(first: 20, filter: { assignedAgent: { eq: "${agentName}" } }, orderBy: [{ createdAt: AscNullsLast }]) { edges { node {
|
`{ leads(first: 20, filter: { assignedAgent: { eq: "${agentName}" } }, orderBy: [{ createdAt: AscNullsLast }]) { edges { node {
|
||||||
@@ -59,10 +71,7 @@ export class WorklistService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getPendingFollowUps(
|
private async getPendingFollowUps(agentName: string, authHeader: string): Promise<any[]> {
|
||||||
agentName: string,
|
|
||||||
authHeader: string,
|
|
||||||
): Promise<any[]> {
|
|
||||||
try {
|
try {
|
||||||
const data = await this.platform.queryWithAuth<any>(
|
const data = await this.platform.queryWithAuth<any>(
|
||||||
`{ followUps(first: 20, filter: { assignedAgent: { eq: "${agentName}" } }) { edges { node {
|
`{ followUps(first: 20, filter: { assignedAgent: { eq: "${agentName}" } }) { edges { node {
|
||||||
@@ -84,10 +93,7 @@ export class WorklistService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getMissedCalls(
|
private async getMissedCalls(agentName: string, authHeader: string): Promise<any[]> {
|
||||||
agentName: string,
|
|
||||||
authHeader: string,
|
|
||||||
): Promise<any[]> {
|
|
||||||
try {
|
try {
|
||||||
// FIFO ordering (AscNullsLast) — oldest first. No agentName filter — missed calls are a shared queue.
|
// FIFO ordering (AscNullsLast) — oldest first. No agentName filter — missed calls are a shared queue.
|
||||||
const data = await this.platform.queryWithAuth<any>(
|
const data = await this.platform.queryWithAuth<any>(
|
||||||
|
|||||||
Reference in New Issue
Block a user