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/openai": "^3.0.41",
|
||||
"@deepgram/sdk": "^5.0.0",
|
||||
"@livekit/agents": "^1.2.1",
|
||||
"@livekit/agents-plugin-google": "^1.2.1",
|
||||
"@livekit/agents-plugin-silero": "^1.2.1",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.3",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
@@ -34,9 +37,12 @@
|
||||
"ai": "^6.0.116",
|
||||
"axios": "^1.13.6",
|
||||
"ioredis": "^5.10.1",
|
||||
"json-rules-engine": "^6.6.0",
|
||||
"kafkajs": "^2.2.4",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"socket.io": "^4.8.3"
|
||||
"socket.io": "^4.8.3",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,27 +13,37 @@ import { WorklistModule } from './worklist/worklist.module';
|
||||
import { CallAssistModule } from './call-assist/call-assist.module';
|
||||
import { SearchModule } from './search/search.module';
|
||||
import { SupervisorModule } from './supervisor/supervisor.module';
|
||||
import { EmbedModule } from './embed/embed.module';
|
||||
import { MaintModule } from './maint/maint.module';
|
||||
import { RecordingsModule } from './recordings/recordings.module';
|
||||
import { EventsModule } from './events/events.module';
|
||||
import { CallerResolutionModule } from './caller/caller-resolution.module';
|
||||
import { RulesEngineModule } from './rules-engine/rules-engine.module';
|
||||
import { ConfigThemeModule } from './config/config-theme.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
load: [configuration],
|
||||
isGlobal: true,
|
||||
}),
|
||||
AiModule,
|
||||
AuthModule,
|
||||
PlatformModule,
|
||||
ExotelModule,
|
||||
CallEventsModule,
|
||||
OzonetelAgentModule,
|
||||
GraphqlProxyModule,
|
||||
HealthModule,
|
||||
WorklistModule,
|
||||
CallAssistModule,
|
||||
SearchModule,
|
||||
SupervisorModule,
|
||||
EmbedModule,
|
||||
],
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
load: [configuration],
|
||||
isGlobal: true,
|
||||
}),
|
||||
AiModule,
|
||||
AuthModule,
|
||||
PlatformModule,
|
||||
ExotelModule,
|
||||
CallEventsModule,
|
||||
OzonetelAgentModule,
|
||||
GraphqlProxyModule,
|
||||
HealthModule,
|
||||
WorklistModule,
|
||||
CallAssistModule,
|
||||
SearchModule,
|
||||
SupervisorModule,
|
||||
MaintModule,
|
||||
RecordingsModule,
|
||||
EventsModule,
|
||||
CallerResolutionModule,
|
||||
RulesEngineModule,
|
||||
ConfigThemeModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
Headers,
|
||||
Req,
|
||||
Logger,
|
||||
HttpException,
|
||||
} from '@nestjs/common';
|
||||
import { Controller, Post, Body, Headers, Req, Logger, HttpException } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
@@ -16,37 +8,30 @@ import { AgentConfigService } from './agent-config.service';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
private readonly logger = new Logger(AuthController.name);
|
||||
private readonly graphqlUrl: string;
|
||||
private readonly workspaceSubdomain: string;
|
||||
private readonly origin: string;
|
||||
private readonly logger = new Logger(AuthController.name);
|
||||
private readonly graphqlUrl: string;
|
||||
private readonly workspaceSubdomain: string;
|
||||
private readonly origin: string;
|
||||
|
||||
constructor(
|
||||
private config: ConfigService,
|
||||
private ozonetelAgent: OzonetelAgentService,
|
||||
private sessionService: SessionService,
|
||||
private agentConfigService: AgentConfigService,
|
||||
) {
|
||||
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
|
||||
this.workspaceSubdomain =
|
||||
process.env.PLATFORM_WORKSPACE_SUBDOMAIN ?? 'fortytwo-dev';
|
||||
this.origin =
|
||||
process.env.PLATFORM_ORIGIN ?? 'http://fortytwo-dev.localhost:4010';
|
||||
}
|
||||
constructor(
|
||||
private config: ConfigService,
|
||||
private ozonetelAgent: OzonetelAgentService,
|
||||
private sessionService: SessionService,
|
||||
private agentConfigService: AgentConfigService,
|
||||
) {
|
||||
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
|
||||
this.workspaceSubdomain = process.env.PLATFORM_WORKSPACE_SUBDOMAIN ?? 'fortytwo-dev';
|
||||
this.origin = process.env.PLATFORM_ORIGIN ?? 'http://fortytwo-dev.localhost:4010';
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
async login(
|
||||
@Body() body: { email: string; password: string },
|
||||
@Req() req: Request,
|
||||
) {
|
||||
this.logger.log(`Login attempt for ${body.email}`);
|
||||
@Post('login')
|
||||
async login(@Body() body: { email: string; password: string }, @Req() req: Request) {
|
||||
this.logger.log(`Login attempt for ${body.email}`);
|
||||
|
||||
try {
|
||||
// Step 1: Get login token
|
||||
const loginRes = await axios.post(
|
||||
this.graphqlUrl,
|
||||
{
|
||||
query: `mutation GetLoginToken($email: String!, $password: String!) {
|
||||
try {
|
||||
// Step 1: Get login token
|
||||
const loginRes = await axios.post(this.graphqlUrl, {
|
||||
query: `mutation GetLoginToken($email: String!, $password: String!) {
|
||||
getLoginTokenFromCredentials(
|
||||
email: $email
|
||||
password: $password
|
||||
@@ -55,31 +40,26 @@ export class AuthController {
|
||||
loginToken { token }
|
||||
}
|
||||
}`,
|
||||
variables: { email: body.email, password: body.password },
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Workspace-Subdomain': this.workspaceSubdomain,
|
||||
},
|
||||
},
|
||||
);
|
||||
variables: { email: body.email, password: body.password },
|
||||
}, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Workspace-Subdomain': this.workspaceSubdomain,
|
||||
},
|
||||
});
|
||||
|
||||
if (loginRes.data.errors) {
|
||||
throw new HttpException(
|
||||
loginRes.data.errors[0]?.message ?? 'Login failed',
|
||||
401,
|
||||
);
|
||||
}
|
||||
if (loginRes.data.errors) {
|
||||
throw new HttpException(
|
||||
loginRes.data.errors[0]?.message ?? 'Login failed',
|
||||
401,
|
||||
);
|
||||
}
|
||||
|
||||
const loginToken =
|
||||
loginRes.data.data.getLoginTokenFromCredentials.loginToken.token;
|
||||
const loginToken = loginRes.data.data.getLoginTokenFromCredentials.loginToken.token;
|
||||
|
||||
// Step 2: Exchange for access + refresh tokens
|
||||
const tokenRes = await axios.post(
|
||||
this.graphqlUrl,
|
||||
{
|
||||
query: `mutation GetAuthTokens($loginToken: String!) {
|
||||
// Step 2: Exchange for access + refresh tokens
|
||||
const tokenRes = await axios.post(this.graphqlUrl, {
|
||||
query: `mutation GetAuthTokens($loginToken: String!) {
|
||||
getAuthTokensFromLoginToken(
|
||||
loginToken: $loginToken
|
||||
origin: "${this.origin}"
|
||||
@@ -90,173 +70,140 @@ export class AuthController {
|
||||
}
|
||||
}
|
||||
}`,
|
||||
variables: { loginToken },
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Workspace-Subdomain': this.workspaceSubdomain,
|
||||
},
|
||||
},
|
||||
);
|
||||
variables: { loginToken },
|
||||
}, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Workspace-Subdomain': this.workspaceSubdomain,
|
||||
},
|
||||
});
|
||||
|
||||
if (tokenRes.data.errors) {
|
||||
throw new HttpException(
|
||||
tokenRes.data.errors[0]?.message ?? 'Token exchange failed',
|
||||
401,
|
||||
);
|
||||
}
|
||||
if (tokenRes.data.errors) {
|
||||
throw new HttpException(
|
||||
tokenRes.data.errors[0]?.message ?? 'Token exchange failed',
|
||||
401,
|
||||
);
|
||||
}
|
||||
|
||||
const tokens = tokenRes.data.data.getAuthTokensFromLoginToken.tokens;
|
||||
const accessToken = tokens.accessOrWorkspaceAgnosticToken.token;
|
||||
const tokens = tokenRes.data.data.getAuthTokensFromLoginToken.tokens;
|
||||
const accessToken = tokens.accessOrWorkspaceAgnosticToken.token;
|
||||
|
||||
// Step 3: Fetch user profile with roles
|
||||
const profileRes = await axios.post(
|
||||
this.graphqlUrl,
|
||||
{
|
||||
query: `{ currentUser { id email workspaceMember { id name { firstName lastName } userEmail avatarUrl roles { id label } } } }`,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
// Step 3: Fetch user profile with roles
|
||||
const profileRes = await axios.post(this.graphqlUrl, {
|
||||
query: `{ currentUser { id email workspaceMember { id name { firstName lastName } userEmail avatarUrl roles { id label } } } }`,
|
||||
}, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
const currentUser = profileRes.data?.data?.currentUser;
|
||||
const workspaceMember = currentUser?.workspaceMember;
|
||||
const roles = workspaceMember?.roles ?? [];
|
||||
const roleLabels = roles.map((r: any) => r.label);
|
||||
const currentUser = profileRes.data?.data?.currentUser;
|
||||
const workspaceMember = currentUser?.workspaceMember;
|
||||
const roles = workspaceMember?.roles ?? [];
|
||||
const roleLabels = roles.map((r: any) => r.label);
|
||||
|
||||
// Determine app role from platform roles
|
||||
let appRole = 'executive'; // default
|
||||
if (roleLabels.includes('HelixEngage Manager')) {
|
||||
appRole = 'admin';
|
||||
} else if (roleLabels.includes('HelixEngage User')) {
|
||||
// Distinguish CC agent from executive by email convention or config
|
||||
// For now, emails containing 'cc' map to cc-agent
|
||||
const email = workspaceMember?.userEmail ?? body.email;
|
||||
appRole = email.includes('cc') ? 'cc-agent' : 'executive';
|
||||
}
|
||||
// Determine app role from platform roles
|
||||
let appRole = 'executive'; // default
|
||||
if (roleLabels.includes('HelixEngage Manager')) {
|
||||
appRole = 'admin';
|
||||
} else if (roleLabels.includes('HelixEngage User')) {
|
||||
// Distinguish CC agent from executive by email convention or config
|
||||
// For now, emails containing 'cc' map to cc-agent
|
||||
const email = workspaceMember?.userEmail ?? body.email;
|
||||
appRole = email.includes('cc') ? 'cc-agent' : 'executive';
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`User ${body.email} logged in with role: ${appRole} (platform roles: ${roleLabels.join(', ')})`,
|
||||
);
|
||||
this.logger.log(`User ${body.email} logged in with role: ${appRole} (platform roles: ${roleLabels.join(', ')})`);
|
||||
|
||||
// Multi-agent: resolve agent config + session lock for CC agents
|
||||
let agentConfigResponse: any = undefined;
|
||||
// Check if user has an Agent entity with SIP config — applies to ALL roles
|
||||
let agentConfigResponse: any = undefined;
|
||||
const memberId = workspaceMember?.id;
|
||||
|
||||
if (appRole === 'cc-agent') {
|
||||
const memberId = workspaceMember?.id;
|
||||
if (!memberId)
|
||||
throw new HttpException('Workspace member not found', 400);
|
||||
if (memberId) {
|
||||
const agentConfig = await this.agentConfigService.getByMemberId(memberId);
|
||||
|
||||
const agentConfig =
|
||||
await this.agentConfigService.getByMemberId(memberId);
|
||||
if (!agentConfig) {
|
||||
throw new HttpException(
|
||||
'Agent account not configured. Contact administrator.',
|
||||
403,
|
||||
);
|
||||
if (agentConfig) {
|
||||
// Agent entity found — set up SIP + Ozonetel
|
||||
const clientIp = (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ?? req.ip ?? 'unknown';
|
||||
const existingSession = await this.sessionService.getSession(agentConfig.ozonetelAgentId);
|
||||
if (existingSession) {
|
||||
this.logger.warn(`Duplicate login blocked for ${body.email} — session held by IP ${existingSession.ip} since ${existingSession.lockedAt}`);
|
||||
throw new HttpException(`You are already logged in from another device (${existingSession.ip}). Please log out there first.`, 409);
|
||||
}
|
||||
|
||||
await this.sessionService.lockSession(agentConfig.ozonetelAgentId, memberId, clientIp);
|
||||
|
||||
this.ozonetelAgent.refreshToken().catch(err => {
|
||||
this.logger.warn(`Ozonetel token refresh on login failed: ${err.message}`);
|
||||
});
|
||||
|
||||
const ozAgentPassword = process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$';
|
||||
this.ozonetelAgent.loginAgent({
|
||||
agentId: agentConfig.ozonetelAgentId,
|
||||
password: ozAgentPassword,
|
||||
phoneNumber: agentConfig.sipExtension,
|
||||
mode: 'blended',
|
||||
}).catch(err => {
|
||||
this.logger.warn(`Ozonetel agent login failed (non-blocking): ${err.message}`);
|
||||
});
|
||||
|
||||
agentConfigResponse = {
|
||||
ozonetelAgentId: agentConfig.ozonetelAgentId,
|
||||
sipExtension: agentConfig.sipExtension,
|
||||
sipPassword: agentConfig.sipPassword,
|
||||
sipUri: agentConfig.sipUri,
|
||||
sipWsServer: agentConfig.sipWsServer,
|
||||
campaignName: agentConfig.campaignName,
|
||||
};
|
||||
|
||||
this.logger.log(`Agent ${body.email} → Ozonetel ${agentConfig.ozonetelAgentId} / SIP ${agentConfig.sipExtension}`);
|
||||
} else if (appRole === 'cc-agent') {
|
||||
// CC agent role but no Agent entity — block login
|
||||
throw new HttpException('Agent account not configured. Contact administrator.', 403);
|
||||
} else {
|
||||
this.logger.log(`User ${body.email} has no Agent entity — SIP disabled`);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
accessToken,
|
||||
refreshToken: tokens.refreshToken.token,
|
||||
user: {
|
||||
id: currentUser?.id,
|
||||
email: currentUser?.email,
|
||||
firstName: workspaceMember?.name?.firstName ?? '',
|
||||
lastName: workspaceMember?.name?.lastName ?? '',
|
||||
avatarUrl: workspaceMember?.avatarUrl,
|
||||
role: appRole,
|
||||
platformRoles: roleLabels,
|
||||
},
|
||||
...(agentConfigResponse ? { agentConfig: agentConfigResponse } : {}),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
this.logger.error(`Login proxy failed: ${error}`);
|
||||
throw new HttpException('Authentication service unavailable', 503);
|
||||
}
|
||||
|
||||
// Check for duplicate login — strict: one device only
|
||||
const clientIp =
|
||||
(req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ??
|
||||
req.ip ??
|
||||
'unknown';
|
||||
const existingSession = await this.sessionService.getSession(
|
||||
agentConfig.ozonetelAgentId,
|
||||
);
|
||||
if (existingSession) {
|
||||
this.logger.warn(
|
||||
`Duplicate login blocked for ${body.email} — session held by IP ${existingSession.ip} since ${existingSession.lockedAt}`,
|
||||
);
|
||||
throw new HttpException(
|
||||
`You are already logged in from another device (${existingSession.ip}). Please log out there first.`,
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
// Lock session in Redis with IP
|
||||
await this.sessionService.lockSession(
|
||||
agentConfig.ozonetelAgentId,
|
||||
memberId,
|
||||
clientIp,
|
||||
);
|
||||
|
||||
// Force-refresh Ozonetel API token on login
|
||||
this.ozonetelAgent.refreshToken().catch((err) => {
|
||||
this.logger.warn(
|
||||
`Ozonetel token refresh on login failed: ${err.message}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Login to Ozonetel with agent-specific credentials
|
||||
const ozAgentPassword =
|
||||
process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$';
|
||||
this.ozonetelAgent
|
||||
.loginAgent({
|
||||
agentId: agentConfig.ozonetelAgentId,
|
||||
password: ozAgentPassword,
|
||||
phoneNumber: agentConfig.sipExtension,
|
||||
mode: 'blended',
|
||||
})
|
||||
.catch((err) => {
|
||||
this.logger.warn(
|
||||
`Ozonetel agent login failed (non-blocking): ${err.message}`,
|
||||
);
|
||||
});
|
||||
|
||||
agentConfigResponse = {
|
||||
ozonetelAgentId: agentConfig.ozonetelAgentId,
|
||||
sipExtension: agentConfig.sipExtension,
|
||||
sipPassword: agentConfig.sipPassword,
|
||||
sipUri: agentConfig.sipUri,
|
||||
sipWsServer: agentConfig.sipWsServer,
|
||||
campaignName: agentConfig.campaignName,
|
||||
};
|
||||
|
||||
this.logger.log(
|
||||
`CC agent ${body.email} → Ozonetel ${agentConfig.ozonetelAgentId} / SIP ${agentConfig.sipExtension}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: tokens.refreshToken.token,
|
||||
user: {
|
||||
id: currentUser?.id,
|
||||
email: currentUser?.email,
|
||||
firstName: workspaceMember?.name?.firstName ?? '',
|
||||
lastName: workspaceMember?.name?.lastName ?? '',
|
||||
avatarUrl: workspaceMember?.avatarUrl,
|
||||
role: appRole,
|
||||
platformRoles: roleLabels,
|
||||
},
|
||||
...(agentConfigResponse ? { agentConfig: agentConfigResponse } : {}),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
this.logger.error(`Login proxy failed: ${error}`);
|
||||
throw new HttpException('Authentication service unavailable', 503);
|
||||
}
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
async refresh(@Body() body: { refreshToken: string }) {
|
||||
if (!body.refreshToken) {
|
||||
throw new HttpException('refreshToken required', 400);
|
||||
}
|
||||
|
||||
this.logger.log('Token refresh request');
|
||||
@Post('refresh')
|
||||
async refresh(@Body() body: { refreshToken: string }) {
|
||||
if (!body.refreshToken) {
|
||||
throw new HttpException('refreshToken required', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await axios.post(
|
||||
this.graphqlUrl,
|
||||
{
|
||||
query: `mutation RefreshToken($token: String!) {
|
||||
this.logger.log('Token refresh request');
|
||||
|
||||
try {
|
||||
const res = await axios.post(this.graphqlUrl, {
|
||||
query: `mutation RefreshToken($token: String!) {
|
||||
renewToken(appToken: $token) {
|
||||
tokens {
|
||||
accessOrWorkspaceAgnosticToken { token expiresAt }
|
||||
@@ -264,101 +211,79 @@ export class AuthController {
|
||||
}
|
||||
}
|
||||
}`,
|
||||
variables: { token: body.refreshToken },
|
||||
},
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
);
|
||||
variables: { token: body.refreshToken },
|
||||
}, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
if (res.data.errors) {
|
||||
this.logger.warn(
|
||||
`Token refresh failed: ${res.data.errors[0]?.message}`,
|
||||
);
|
||||
throw new HttpException('Token refresh failed', 401);
|
||||
}
|
||||
if (res.data.errors) {
|
||||
this.logger.warn(`Token refresh failed: ${res.data.errors[0]?.message}`);
|
||||
throw new HttpException('Token refresh failed', 401);
|
||||
}
|
||||
|
||||
const tokens = res.data.data.renewToken.tokens;
|
||||
return {
|
||||
accessToken: tokens.accessOrWorkspaceAgnosticToken.token,
|
||||
refreshToken: tokens.refreshToken.token,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
this.logger.error(`Token refresh failed: ${error}`);
|
||||
throw new HttpException('Token refresh failed', 401);
|
||||
const tokens = res.data.data.renewToken.tokens;
|
||||
return {
|
||||
accessToken: tokens.accessOrWorkspaceAgnosticToken.token,
|
||||
refreshToken: tokens.refreshToken.token,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
this.logger.error(`Token refresh failed: ${error}`);
|
||||
throw new HttpException('Token refresh failed', 401);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
async logout(@Headers('authorization') auth: string) {
|
||||
if (!auth) return { status: 'ok' };
|
||||
@Post('logout')
|
||||
async logout(@Headers('authorization') auth: string) {
|
||||
if (!auth) return { status: 'ok' };
|
||||
|
||||
try {
|
||||
const profileRes = await axios.post(
|
||||
this.graphqlUrl,
|
||||
{
|
||||
query: '{ currentUser { workspaceMember { id } } }',
|
||||
},
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json', Authorization: auth },
|
||||
},
|
||||
);
|
||||
try {
|
||||
const profileRes = await axios.post(this.graphqlUrl, {
|
||||
query: '{ currentUser { workspaceMember { id } } }',
|
||||
}, { headers: { 'Content-Type': 'application/json', Authorization: auth } });
|
||||
|
||||
const memberId = profileRes.data?.data?.currentUser?.workspaceMember?.id;
|
||||
if (!memberId) return { status: 'ok' };
|
||||
const memberId = profileRes.data?.data?.currentUser?.workspaceMember?.id;
|
||||
if (!memberId) return { status: 'ok' };
|
||||
|
||||
const agentConfig = this.agentConfigService.getFromCache(memberId);
|
||||
if (agentConfig) {
|
||||
await this.sessionService.unlockSession(agentConfig.ozonetelAgentId);
|
||||
this.logger.log(`Session unlocked for ${agentConfig.ozonetelAgentId}`);
|
||||
const agentConfig = this.agentConfigService.getFromCache(memberId);
|
||||
if (agentConfig) {
|
||||
await this.sessionService.unlockSession(agentConfig.ozonetelAgentId);
|
||||
this.logger.log(`Session unlocked for ${agentConfig.ozonetelAgentId}`);
|
||||
|
||||
this.ozonetelAgent
|
||||
.logoutAgent({
|
||||
agentId: agentConfig.ozonetelAgentId,
|
||||
password: process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$',
|
||||
})
|
||||
.catch((err) =>
|
||||
this.logger.warn(`Ozonetel logout failed: ${err.message}`),
|
||||
);
|
||||
this.ozonetelAgent.logoutAgent({
|
||||
agentId: agentConfig.ozonetelAgentId,
|
||||
password: process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$',
|
||||
}).catch(err => this.logger.warn(`Ozonetel logout failed: ${err.message}`));
|
||||
|
||||
this.agentConfigService.clearCache(memberId);
|
||||
}
|
||||
this.agentConfigService.clearCache(memberId);
|
||||
}
|
||||
|
||||
return { status: 'ok' };
|
||||
} catch (err) {
|
||||
this.logger.warn(`Logout cleanup failed: ${err}`);
|
||||
return { status: 'ok' };
|
||||
return { status: 'ok' };
|
||||
} catch (err) {
|
||||
this.logger.warn(`Logout cleanup failed: ${err}`);
|
||||
return { status: 'ok' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Post('heartbeat')
|
||||
async heartbeat(@Headers('authorization') auth: string) {
|
||||
if (!auth) return { status: 'ok' };
|
||||
@Post('heartbeat')
|
||||
async heartbeat(@Headers('authorization') auth: string) {
|
||||
if (!auth) return { status: 'ok' };
|
||||
|
||||
try {
|
||||
const profileRes = await axios.post(
|
||||
this.graphqlUrl,
|
||||
{
|
||||
query: '{ currentUser { workspaceMember { id } } }',
|
||||
},
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json', Authorization: auth },
|
||||
},
|
||||
);
|
||||
try {
|
||||
const profileRes = await axios.post(this.graphqlUrl, {
|
||||
query: '{ currentUser { workspaceMember { id } } }',
|
||||
}, { headers: { 'Content-Type': 'application/json', Authorization: auth } });
|
||||
|
||||
const memberId = profileRes.data?.data?.currentUser?.workspaceMember?.id;
|
||||
const agentConfig = memberId
|
||||
? this.agentConfigService.getFromCache(memberId)
|
||||
: null;
|
||||
const memberId = profileRes.data?.data?.currentUser?.workspaceMember?.id;
|
||||
const agentConfig = memberId ? this.agentConfigService.getFromCache(memberId) : null;
|
||||
|
||||
if (agentConfig) {
|
||||
await this.sessionService.refreshSession(agentConfig.ozonetelAgentId);
|
||||
}
|
||||
if (agentConfig) {
|
||||
await this.sessionService.refreshSession(agentConfig.ozonetelAgentId);
|
||||
}
|
||||
|
||||
return { status: 'ok' };
|
||||
} catch {
|
||||
return { status: 'ok' };
|
||||
return { status: 'ok' };
|
||||
} catch {
|
||||
return { status: 'ok' };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,60 +6,72 @@ const SESSION_TTL = 3600; // 1 hour
|
||||
|
||||
@Injectable()
|
||||
export class SessionService implements OnModuleInit {
|
||||
private readonly logger = new Logger(SessionService.name);
|
||||
private redis: Redis;
|
||||
private readonly logger = new Logger(SessionService.name);
|
||||
private redis: Redis;
|
||||
|
||||
constructor(private config: ConfigService) {}
|
||||
constructor(private config: ConfigService) {}
|
||||
|
||||
onModuleInit() {
|
||||
const url = this.config.get<string>('redis.url', 'redis://localhost:6379');
|
||||
this.redis = new Redis(url);
|
||||
this.redis.on('connect', () => this.logger.log('Redis connected'));
|
||||
this.redis.on('error', (err) =>
|
||||
this.logger.error(`Redis error: ${err.message}`),
|
||||
);
|
||||
}
|
||||
|
||||
private key(agentId: string): string {
|
||||
return `agent:session:${agentId}`;
|
||||
}
|
||||
|
||||
async lockSession(
|
||||
agentId: string,
|
||||
memberId: string,
|
||||
ip?: string,
|
||||
): Promise<void> {
|
||||
const value = JSON.stringify({
|
||||
memberId,
|
||||
ip: ip ?? 'unknown',
|
||||
lockedAt: new Date().toISOString(),
|
||||
});
|
||||
await this.redis.set(this.key(agentId), value, 'EX', SESSION_TTL);
|
||||
}
|
||||
|
||||
async getSession(
|
||||
agentId: string,
|
||||
): Promise<{ memberId: string; ip: string; lockedAt: string } | null> {
|
||||
const raw = await this.redis.get(this.key(agentId));
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
// Legacy format — just memberId string
|
||||
return { memberId: raw, ip: 'unknown', lockedAt: 'unknown' };
|
||||
onModuleInit() {
|
||||
const url = this.config.get<string>('redis.url', 'redis://localhost:6379');
|
||||
this.redis = new Redis(url);
|
||||
this.redis.on('connect', () => this.logger.log('Redis connected'));
|
||||
this.redis.on('error', (err) => this.logger.error(`Redis error: ${err.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
async isSessionLocked(agentId: string): Promise<string | null> {
|
||||
const session = await this.getSession(agentId);
|
||||
return session ? session.memberId : null;
|
||||
}
|
||||
private key(agentId: string): string {
|
||||
return `agent:session:${agentId}`;
|
||||
}
|
||||
|
||||
async refreshSession(agentId: string): Promise<void> {
|
||||
await this.redis.expire(this.key(agentId), SESSION_TTL);
|
||||
}
|
||||
async lockSession(agentId: string, memberId: string, ip?: string): Promise<void> {
|
||||
const value = JSON.stringify({ memberId, ip: ip ?? 'unknown', lockedAt: new Date().toISOString() });
|
||||
await this.redis.set(this.key(agentId), value, 'EX', SESSION_TTL);
|
||||
}
|
||||
|
||||
async unlockSession(agentId: string): Promise<void> {
|
||||
await this.redis.del(this.key(agentId));
|
||||
}
|
||||
async getSession(agentId: string): Promise<{ memberId: string; ip: string; lockedAt: string } | null> {
|
||||
const raw = await this.redis.get(this.key(agentId));
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
// Legacy format — just memberId string
|
||||
return { memberId: raw, ip: 'unknown', lockedAt: 'unknown' };
|
||||
}
|
||||
}
|
||||
|
||||
async isSessionLocked(agentId: string): Promise<string | null> {
|
||||
const session = await this.getSession(agentId);
|
||||
return session ? session.memberId : null;
|
||||
}
|
||||
|
||||
async refreshSession(agentId: string): Promise<void> {
|
||||
await this.redis.expire(this.key(agentId), SESSION_TTL);
|
||||
}
|
||||
|
||||
async unlockSession(agentId: string): Promise<void> {
|
||||
await this.redis.del(this.key(agentId));
|
||||
}
|
||||
|
||||
// Generic cache operations for any module
|
||||
async getCache(key: string): Promise<string | null> {
|
||||
return this.redis.get(key);
|
||||
}
|
||||
|
||||
async setCache(key: string, value: string, ttlSeconds: number): Promise<void> {
|
||||
await this.redis.set(key, value, 'EX', ttlSeconds);
|
||||
}
|
||||
|
||||
async 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,79 +1,90 @@
|
||||
import {
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
SubscribeMessage,
|
||||
MessageBody,
|
||||
ConnectedSocket,
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
SubscribeMessage,
|
||||
MessageBody,
|
||||
ConnectedSocket,
|
||||
} from '@nestjs/websockets';
|
||||
import { Logger, Inject, forwardRef } from '@nestjs/common';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import type {
|
||||
EnrichedCallEvent,
|
||||
DispositionPayload,
|
||||
} from './call-events.types';
|
||||
import type { EnrichedCallEvent, DispositionPayload } from './call-events.types';
|
||||
import { CallEventsService } from './call-events.service';
|
||||
|
||||
@WebSocketGateway({
|
||||
cors: {
|
||||
origin: process.env.CORS_ORIGIN ?? 'http://localhost:5173',
|
||||
credentials: true,
|
||||
},
|
||||
namespace: '/call-events',
|
||||
cors: {
|
||||
origin: process.env.CORS_ORIGIN ?? 'http://localhost:5173',
|
||||
credentials: true,
|
||||
},
|
||||
namespace: '/call-events',
|
||||
})
|
||||
export class CallEventsGateway {
|
||||
@WebSocketServer()
|
||||
server: Server;
|
||||
@WebSocketServer()
|
||||
server: Server;
|
||||
|
||||
private readonly logger = new Logger(CallEventsGateway.name);
|
||||
private readonly logger = new Logger(CallEventsGateway.name);
|
||||
|
||||
constructor(
|
||||
@Inject(forwardRef(() => CallEventsService))
|
||||
private readonly callEventsService: CallEventsService,
|
||||
) {}
|
||||
constructor(
|
||||
@Inject(forwardRef(() => CallEventsService))
|
||||
private readonly callEventsService: CallEventsService,
|
||||
) {}
|
||||
|
||||
// Push enriched call event to a specific agent's room
|
||||
pushCallEvent(agentName: string, event: EnrichedCallEvent) {
|
||||
const room = `agent:${agentName}`;
|
||||
this.logger.log(`Pushing ${event.eventType} event to room ${room}`);
|
||||
this.server.to(room).emit('call:incoming', event);
|
||||
}
|
||||
// Push enriched call event to a specific agent's room
|
||||
pushCallEvent(agentName: string, event: EnrichedCallEvent) {
|
||||
const room = `agent:${agentName}`;
|
||||
this.logger.log(`Pushing ${event.eventType} event to room ${room}`);
|
||||
this.server.to(room).emit('call:incoming', event);
|
||||
}
|
||||
|
||||
// Agent registers when they open the Call Desk page
|
||||
@SubscribeMessage('agent:register')
|
||||
handleAgentRegister(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() agentName: string,
|
||||
) {
|
||||
const room = `agent:${agentName}`;
|
||||
client.join(room);
|
||||
this.logger.log(
|
||||
`Agent ${agentName} registered in room ${room} (socket: ${client.id})`,
|
||||
);
|
||||
client.emit('agent:registered', { agentName, room });
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Agent sends disposition after a call
|
||||
@SubscribeMessage('call:disposition')
|
||||
async handleDisposition(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() payload: DispositionPayload,
|
||||
) {
|
||||
this.logger.log(
|
||||
`Disposition received from ${payload.agentName}: ${payload.disposition}`,
|
||||
);
|
||||
await this.callEventsService.handleDisposition(payload);
|
||||
client.emit('call:disposition:ack', {
|
||||
status: 'saved',
|
||||
callSid: payload.callSid,
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
// 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' });
|
||||
}
|
||||
|
||||
handleConnection(client: Socket) {
|
||||
this.logger.log(`Client connected: ${client.id}`);
|
||||
}
|
||||
// Agent registers when they open the Call Desk page
|
||||
@SubscribeMessage('agent:register')
|
||||
handleAgentRegister(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() agentName: string,
|
||||
) {
|
||||
const room = `agent:${agentName}`;
|
||||
client.join(room);
|
||||
this.logger.log(
|
||||
`Agent ${agentName} registered in room ${room} (socket: ${client.id})`,
|
||||
);
|
||||
client.emit('agent:registered', { agentName, room });
|
||||
}
|
||||
|
||||
handleDisconnect(client: Socket) {
|
||||
this.logger.log(`Client disconnected: ${client.id}`);
|
||||
}
|
||||
// Agent sends disposition after a call
|
||||
@SubscribeMessage('call:disposition')
|
||||
async handleDisposition(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() payload: DispositionPayload,
|
||||
) {
|
||||
this.logger.log(
|
||||
`Disposition received from ${payload.agentName}: ${payload.disposition}`,
|
||||
);
|
||||
await this.callEventsService.handleDisposition(payload);
|
||||
client.emit('call:disposition:ack', {
|
||||
status: 'saved',
|
||||
callSid: payload.callSid,
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
|
||||
handleConnection(client: Socket) {
|
||||
this.logger.log(`Client connected: ${client.id}`);
|
||||
}
|
||||
|
||||
handleDisconnect(client: Socket) {
|
||||
this.logger.log(`Client disconnected: ${client.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,217 +4,251 @@ import { AiEnrichmentService } from '../ai/ai-enrichment.service';
|
||||
import { CallEventsGateway } from './call-events.gateway';
|
||||
import type { CallEvent } from '../exotel/exotel.types';
|
||||
import type {
|
||||
EnrichedCallEvent,
|
||||
DispositionPayload,
|
||||
EnrichedCallEvent,
|
||||
DispositionPayload,
|
||||
} from './call-events.types';
|
||||
|
||||
const DISPOSITION_TO_LEAD_STATUS: Record<string, string> = {
|
||||
APPOINTMENT_BOOKED: 'APPOINTMENT_SET',
|
||||
FOLLOW_UP_SCHEDULED: 'CONTACTED',
|
||||
INFO_PROVIDED: 'CONTACTED',
|
||||
CALLBACK_REQUESTED: 'CONTACTED',
|
||||
WRONG_NUMBER: 'LOST',
|
||||
NO_ANSWER: 'CONTACTED',
|
||||
NOT_INTERESTED: 'LOST',
|
||||
APPOINTMENT_BOOKED: 'APPOINTMENT_SET',
|
||||
FOLLOW_UP_SCHEDULED: 'CONTACTED',
|
||||
INFO_PROVIDED: 'CONTACTED',
|
||||
CALLBACK_REQUESTED: 'CONTACTED',
|
||||
WRONG_NUMBER: 'LOST',
|
||||
NO_ANSWER: 'CONTACTED',
|
||||
NOT_INTERESTED: 'LOST',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CallEventsService {
|
||||
private readonly logger = new Logger(CallEventsService.name);
|
||||
private readonly logger = new Logger(CallEventsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly platform: PlatformGraphqlService,
|
||||
private readonly ai: AiEnrichmentService,
|
||||
@Inject(forwardRef(() => CallEventsGateway))
|
||||
private readonly gateway: CallEventsGateway,
|
||||
) {}
|
||||
constructor(
|
||||
private readonly platform: PlatformGraphqlService,
|
||||
private readonly ai: AiEnrichmentService,
|
||||
@Inject(forwardRef(() => CallEventsGateway))
|
||||
private readonly gateway: CallEventsGateway,
|
||||
) {}
|
||||
|
||||
async handleIncomingCall(callEvent: CallEvent): Promise<void> {
|
||||
this.logger.log(
|
||||
`Processing incoming call from ${callEvent.callerPhone} to agent ${callEvent.agentName}`,
|
||||
);
|
||||
|
||||
// 1. Lookup lead by phone
|
||||
let lead = null;
|
||||
try {
|
||||
lead = await this.platform.findLeadByPhone(callEvent.callerPhone);
|
||||
if (lead) {
|
||||
async handleIncomingCall(callEvent: CallEvent): Promise<void> {
|
||||
this.logger.log(
|
||||
`Matched lead: ${lead.contactName?.firstName} ${lead.contactName?.lastName} (${lead.id})`,
|
||||
`Processing incoming call from ${callEvent.callerPhone} to agent ${callEvent.agentName}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.log(`No lead found for phone ${callEvent.callerPhone}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Lead lookup failed: ${error}`);
|
||||
}
|
||||
|
||||
// 2. AI enrichment (if lead found and no existing summary)
|
||||
if (lead && !lead.aiSummary) {
|
||||
try {
|
||||
const activities = await this.platform.getLeadActivities(lead.id, 5);
|
||||
const enrichment = await this.ai.enrichLead({
|
||||
firstName: lead.contactName?.firstName,
|
||||
lastName: lead.contactName?.lastName,
|
||||
leadSource: lead.leadSource ?? undefined,
|
||||
interestedService: lead.interestedService ?? undefined,
|
||||
leadStatus: lead.leadStatus ?? undefined,
|
||||
contactAttempts: lead.contactAttempts ?? undefined,
|
||||
createdAt: lead.createdAt,
|
||||
activities: activities.map((a) => ({
|
||||
activityType: a.activityType ?? '',
|
||||
summary: a.summary ?? '',
|
||||
})),
|
||||
});
|
||||
|
||||
// Persist AI enrichment back to platform
|
||||
await this.platform.updateLead(lead.id, enrichment);
|
||||
lead.aiSummary = enrichment.aiSummary;
|
||||
lead.aiSuggestedAction = enrichment.aiSuggestedAction;
|
||||
|
||||
this.logger.log(`AI enrichment applied for lead ${lead.id}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`AI enrichment failed: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Get recent activities for display
|
||||
let recentActivities: {
|
||||
activityType: string;
|
||||
summary: string;
|
||||
occurredAt: string;
|
||||
performedBy: string;
|
||||
}[] = [];
|
||||
if (lead) {
|
||||
try {
|
||||
const activities = await this.platform.getLeadActivities(lead.id, 3);
|
||||
recentActivities = activities.map((a) => ({
|
||||
activityType: a.activityType ?? '',
|
||||
summary: a.summary ?? '',
|
||||
occurredAt: a.occurredAt ?? '',
|
||||
performedBy: a.performedBy ?? '',
|
||||
}));
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch activities: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Build enriched event
|
||||
const daysSinceCreation = lead?.createdAt
|
||||
? Math.floor(
|
||||
(Date.now() - new Date(lead.createdAt).getTime()) /
|
||||
(1000 * 60 * 60 * 24),
|
||||
)
|
||||
: 0;
|
||||
|
||||
const enrichedEvent: EnrichedCallEvent = {
|
||||
callSid: callEvent.exotelCallSid,
|
||||
eventType: callEvent.eventType,
|
||||
lead: lead
|
||||
? {
|
||||
id: lead.id,
|
||||
firstName: lead.contactName?.firstName ?? 'Unknown',
|
||||
lastName: lead.contactName?.lastName ?? '',
|
||||
phone: lead.contactPhone?.[0]
|
||||
? `${lead.contactPhone[0].callingCode} ${lead.contactPhone[0].number}`
|
||||
: callEvent.callerPhone,
|
||||
email: lead.contactEmail?.[0]?.address,
|
||||
source: lead.leadSource ?? undefined,
|
||||
status: lead.leadStatus ?? undefined,
|
||||
interestedService: lead.interestedService ?? undefined,
|
||||
age: daysSinceCreation,
|
||||
aiSummary: lead.aiSummary ?? undefined,
|
||||
aiSuggestedAction: lead.aiSuggestedAction ?? undefined,
|
||||
recentActivities,
|
||||
}
|
||||
: null,
|
||||
callerPhone: callEvent.callerPhone,
|
||||
agentName: callEvent.agentName,
|
||||
timestamp: callEvent.timestamp,
|
||||
};
|
||||
|
||||
// 5. Push to agent's browser via WebSocket
|
||||
this.gateway.pushCallEvent(callEvent.agentName, enrichedEvent);
|
||||
}
|
||||
|
||||
async handleCallEnded(callEvent: CallEvent): Promise<void> {
|
||||
this.logger.log(`Call ended: ${callEvent.exotelCallSid}`);
|
||||
|
||||
const enrichedEvent: EnrichedCallEvent = {
|
||||
callSid: callEvent.exotelCallSid,
|
||||
eventType: 'ended',
|
||||
lead: null,
|
||||
callerPhone: callEvent.callerPhone,
|
||||
agentName: callEvent.agentName,
|
||||
timestamp: callEvent.timestamp,
|
||||
};
|
||||
|
||||
this.gateway.pushCallEvent(callEvent.agentName, enrichedEvent);
|
||||
}
|
||||
|
||||
async handleDisposition(payload: DispositionPayload): Promise<void> {
|
||||
this.logger.log(
|
||||
`Processing disposition: ${payload.disposition} for call ${payload.callSid}`,
|
||||
);
|
||||
|
||||
// 1. Create Call record in platform
|
||||
try {
|
||||
await this.platform.createCall({
|
||||
callDirection: 'INBOUND',
|
||||
callStatus: 'COMPLETED',
|
||||
callerNumber: payload.callerPhone
|
||||
? [
|
||||
{
|
||||
number: payload.callerPhone.replace(/\D/g, ''),
|
||||
callingCode: '+91',
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
agentName: payload.agentName,
|
||||
startedAt: payload.startedAt,
|
||||
endedAt: new Date().toISOString(),
|
||||
durationSeconds: payload.duration,
|
||||
disposition: payload.disposition,
|
||||
callNotes: payload.notes || undefined,
|
||||
leadId: payload.leadId || undefined,
|
||||
});
|
||||
this.logger.log(`Call record created for ${payload.callSid}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to create call record: ${error}`);
|
||||
}
|
||||
|
||||
// 2. Update lead status based on disposition
|
||||
if (payload.leadId) {
|
||||
const newStatus = DISPOSITION_TO_LEAD_STATUS[payload.disposition];
|
||||
if (newStatus) {
|
||||
// 1. Lookup lead by phone
|
||||
let lead = null;
|
||||
try {
|
||||
await this.platform.updateLead(payload.leadId, {
|
||||
leadStatus: newStatus,
|
||||
lastContactedAt: new Date().toISOString(),
|
||||
});
|
||||
this.logger.log(
|
||||
`Lead ${payload.leadId} status updated to ${newStatus}`,
|
||||
);
|
||||
lead = await this.platform.findLeadByPhone(callEvent.callerPhone);
|
||||
if (lead) {
|
||||
this.logger.log(
|
||||
`Matched lead: ${lead.contactName?.firstName} ${lead.contactName?.lastName} (${lead.id})`,
|
||||
);
|
||||
} else {
|
||||
this.logger.log(
|
||||
`No lead found for phone ${callEvent.callerPhone}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to update lead: ${error}`);
|
||||
this.logger.error(`Lead lookup failed: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Create lead activity
|
||||
try {
|
||||
await this.platform.createLeadActivity({
|
||||
activityType: 'CALL_RECEIVED',
|
||||
summary: `Inbound call — ${payload.disposition.replace(/_/g, ' ')}`,
|
||||
occurredAt: new Date().toISOString(),
|
||||
performedBy: payload.agentName,
|
||||
channel: 'PHONE',
|
||||
durationSeconds: payload.duration,
|
||||
leadId: payload.leadId,
|
||||
});
|
||||
this.logger.log(`Lead activity logged for ${payload.leadId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to create lead activity: ${error}`);
|
||||
}
|
||||
// 2. AI enrichment (if lead found and no existing summary)
|
||||
if (lead && !lead.aiSummary) {
|
||||
try {
|
||||
const activities = await this.platform.getLeadActivities(
|
||||
lead.id,
|
||||
5,
|
||||
);
|
||||
const enrichment = await this.ai.enrichLead({
|
||||
firstName: lead.contactName?.firstName,
|
||||
lastName: lead.contactName?.lastName,
|
||||
leadSource: lead.leadSource ?? undefined,
|
||||
interestedService: lead.interestedService ?? undefined,
|
||||
leadStatus: lead.leadStatus ?? undefined,
|
||||
contactAttempts: lead.contactAttempts ?? undefined,
|
||||
createdAt: lead.createdAt,
|
||||
activities: activities.map((a) => ({
|
||||
activityType: a.activityType ?? '',
|
||||
summary: a.summary ?? '',
|
||||
})),
|
||||
});
|
||||
|
||||
// Persist AI enrichment back to platform
|
||||
await this.platform.updateLead(lead.id, enrichment);
|
||||
lead.aiSummary = enrichment.aiSummary;
|
||||
lead.aiSuggestedAction = enrichment.aiSuggestedAction;
|
||||
|
||||
this.logger.log(`AI enrichment applied for lead ${lead.id}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`AI enrichment failed: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Get recent activities for display
|
||||
let recentActivities: {
|
||||
activityType: string;
|
||||
summary: string;
|
||||
occurredAt: string;
|
||||
performedBy: string;
|
||||
}[] = [];
|
||||
if (lead) {
|
||||
try {
|
||||
const activities = await this.platform.getLeadActivities(
|
||||
lead.id,
|
||||
3,
|
||||
);
|
||||
recentActivities = activities.map((a) => ({
|
||||
activityType: a.activityType ?? '',
|
||||
summary: a.summary ?? '',
|
||||
occurredAt: a.occurredAt ?? '',
|
||||
performedBy: a.performedBy ?? '',
|
||||
}));
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch activities: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Build enriched event
|
||||
const daysSinceCreation = lead?.createdAt
|
||||
? Math.floor(
|
||||
(Date.now() - new Date(lead.createdAt).getTime()) /
|
||||
(1000 * 60 * 60 * 24),
|
||||
)
|
||||
: 0;
|
||||
|
||||
const enrichedEvent: EnrichedCallEvent = {
|
||||
callSid: callEvent.exotelCallSid,
|
||||
eventType: callEvent.eventType,
|
||||
lead: lead
|
||||
? {
|
||||
id: lead.id,
|
||||
firstName: lead.contactName?.firstName ?? 'Unknown',
|
||||
lastName: lead.contactName?.lastName ?? '',
|
||||
phone: lead.contactPhone?.[0]
|
||||
? `${lead.contactPhone[0].callingCode} ${lead.contactPhone[0].number}`
|
||||
: callEvent.callerPhone,
|
||||
email: lead.contactEmail?.[0]?.address,
|
||||
source: lead.leadSource ?? undefined,
|
||||
status: lead.leadStatus ?? undefined,
|
||||
interestedService:
|
||||
lead.interestedService ?? undefined,
|
||||
age: daysSinceCreation,
|
||||
aiSummary: lead.aiSummary ?? undefined,
|
||||
aiSuggestedAction:
|
||||
lead.aiSuggestedAction ?? undefined,
|
||||
recentActivities,
|
||||
}
|
||||
: null,
|
||||
callerPhone: callEvent.callerPhone,
|
||||
agentName: callEvent.agentName,
|
||||
timestamp: callEvent.timestamp,
|
||||
};
|
||||
|
||||
// 5. Push to agent's browser via WebSocket
|
||||
this.gateway.pushCallEvent(callEvent.agentName, enrichedEvent);
|
||||
}
|
||||
|
||||
async handleCallEnded(callEvent: CallEvent): Promise<void> {
|
||||
this.logger.log(`Call ended: ${callEvent.exotelCallSid}`);
|
||||
|
||||
const enrichedEvent: EnrichedCallEvent = {
|
||||
callSid: callEvent.exotelCallSid,
|
||||
eventType: 'ended',
|
||||
lead: null,
|
||||
callerPhone: callEvent.callerPhone,
|
||||
agentName: callEvent.agentName,
|
||||
timestamp: callEvent.timestamp,
|
||||
};
|
||||
|
||||
this.gateway.pushCallEvent(callEvent.agentName, enrichedEvent);
|
||||
}
|
||||
|
||||
async handleDisposition(payload: DispositionPayload): Promise<void> {
|
||||
this.logger.log(
|
||||
`Processing disposition: ${payload.disposition} for call ${payload.callSid}`,
|
||||
);
|
||||
|
||||
// 1. Compute SLA % if lead is linked
|
||||
let sla: number | undefined;
|
||||
if (payload.leadId && payload.startedAt) {
|
||||
try {
|
||||
const lead = await this.platform.findLeadById(payload.leadId);
|
||||
if (lead?.createdAt) {
|
||||
const leadCreated = new Date(lead.createdAt).getTime();
|
||||
const callStarted = new Date(payload.startedAt).getTime();
|
||||
const elapsedMin = Math.max(0, (callStarted - leadCreated) / 60000);
|
||||
const slaThresholdMin = 1440; // Default 24h; missed calls use 720 but this is a completed call
|
||||
sla = Math.round((elapsedMin / slaThresholdMin) * 100);
|
||||
}
|
||||
} catch {
|
||||
// SLA computation is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Create Call record in platform
|
||||
try {
|
||||
await this.platform.createCall({
|
||||
callDirection: 'INBOUND',
|
||||
callStatus: 'COMPLETED',
|
||||
callerNumber: payload.callerPhone
|
||||
? [
|
||||
{
|
||||
number: payload.callerPhone.replace(/\D/g, ''),
|
||||
callingCode: '+91',
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
agentName: payload.agentName,
|
||||
startedAt: payload.startedAt,
|
||||
endedAt: new Date().toISOString(),
|
||||
durationSeconds: payload.duration,
|
||||
disposition: payload.disposition,
|
||||
callNotes: payload.notes || undefined,
|
||||
leadId: payload.leadId || undefined,
|
||||
sla,
|
||||
});
|
||||
this.logger.log(`Call record created for ${payload.callSid} (SLA: ${sla ?? 'N/A'}%)`);
|
||||
// Notify supervisors in real-time
|
||||
this.gateway.broadcastCallCreated({ callSid: payload.callSid, agentName: payload.agentName, disposition: payload.disposition });
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to create call record: ${error}`);
|
||||
}
|
||||
|
||||
// 2. Update lead status based on disposition
|
||||
if (payload.leadId) {
|
||||
const newStatus = DISPOSITION_TO_LEAD_STATUS[payload.disposition];
|
||||
if (newStatus) {
|
||||
try {
|
||||
await this.platform.updateLead(payload.leadId, {
|
||||
leadStatus: newStatus,
|
||||
lastContactedAt: new Date().toISOString(),
|
||||
});
|
||||
this.logger.log(
|
||||
`Lead ${payload.leadId} status updated to ${newStatus}`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to update lead: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Create lead activity
|
||||
try {
|
||||
await this.platform.createLeadActivity({
|
||||
activityType: 'CALL_RECEIVED',
|
||||
summary: `Inbound call — ${payload.disposition.replace(/_/g, ' ')}`,
|
||||
occurredAt: new Date().toISOString(),
|
||||
performedBy: payload.agentName,
|
||||
channel: 'PHONE',
|
||||
durationSeconds: payload.duration,
|
||||
leadId: payload.leadId,
|
||||
});
|
||||
this.logger.log(
|
||||
`Lead activity logged for ${payload.leadId}`,
|
||||
);
|
||||
} catch (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,398 +1,336 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Body,
|
||||
Query,
|
||||
Logger,
|
||||
HttpException,
|
||||
} from '@nestjs/common';
|
||||
import { Controller, Post, Get, Body, Query, Logger, HttpException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { OzonetelAgentService } from './ozonetel-agent.service';
|
||||
import { MissedQueueService } from '../worklist/missed-queue.service';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { EventBusService } from '../events/event-bus.service';
|
||||
import { Topics } from '../events/event-types';
|
||||
|
||||
@Controller('api/ozonetel')
|
||||
export class OzonetelAgentController {
|
||||
private readonly logger = new Logger(OzonetelAgentController.name);
|
||||
private readonly defaultAgentId: string;
|
||||
private readonly defaultAgentPassword: string;
|
||||
private readonly logger = new Logger(OzonetelAgentController.name);
|
||||
private readonly defaultAgentId: string;
|
||||
private readonly defaultAgentPassword: string;
|
||||
|
||||
private readonly defaultSipId: string;
|
||||
private readonly defaultSipId: string;
|
||||
|
||||
constructor(
|
||||
private readonly ozonetelAgent: OzonetelAgentService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly missedQueue: MissedQueueService,
|
||||
private readonly platform: PlatformGraphqlService,
|
||||
) {
|
||||
this.defaultAgentId = config.get<string>('OZONETEL_AGENT_ID') ?? 'agent3';
|
||||
this.defaultAgentPassword =
|
||||
config.get<string>('OZONETEL_AGENT_PASSWORD') ?? '';
|
||||
this.defaultSipId = config.get<string>('OZONETEL_SIP_ID') ?? '521814';
|
||||
}
|
||||
|
||||
@Post('agent-login')
|
||||
async agentLogin(
|
||||
@Body()
|
||||
body: {
|
||||
agentId: string;
|
||||
password: string;
|
||||
phoneNumber: string;
|
||||
mode?: string;
|
||||
},
|
||||
) {
|
||||
this.logger.log(`Agent login request for ${body.agentId}`);
|
||||
|
||||
try {
|
||||
const result = await this.ozonetelAgent.loginAgent(body);
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
throw new HttpException(
|
||||
error.response?.data?.message ?? 'Agent login failed',
|
||||
error.response?.status ?? 500,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Post('agent-logout')
|
||||
async agentLogout(@Body() body: { agentId: string; password: string }) {
|
||||
this.logger.log(`Agent logout request for ${body.agentId}`);
|
||||
|
||||
try {
|
||||
const result = await this.ozonetelAgent.logoutAgent(body);
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
throw new HttpException(
|
||||
error.response?.data?.message ?? 'Agent logout failed',
|
||||
error.response?.status ?? 500,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Post('agent-state')
|
||||
async agentState(
|
||||
@Body() body: { state: 'Ready' | 'Pause'; pauseReason?: string },
|
||||
) {
|
||||
if (!body.state) {
|
||||
throw new HttpException('state required', 400);
|
||||
constructor(
|
||||
private readonly ozonetelAgent: OzonetelAgentService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly missedQueue: MissedQueueService,
|
||||
private readonly platform: PlatformGraphqlService,
|
||||
private readonly eventBus: EventBusService,
|
||||
) {
|
||||
this.defaultAgentId = config.get<string>('OZONETEL_AGENT_ID') ?? 'agent3';
|
||||
this.defaultAgentPassword = config.get<string>('OZONETEL_AGENT_PASSWORD') ?? '';
|
||||
this.defaultSipId = config.get<string>('OZONETEL_SIP_ID') ?? '521814';
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Agent state change: ${this.defaultAgentId} → ${body.state} (${body.pauseReason ?? ''})`,
|
||||
);
|
||||
@Post('agent-login')
|
||||
async agentLogin(
|
||||
@Body() body: { agentId: string; password: string; phoneNumber: string; mode?: string },
|
||||
) {
|
||||
this.logger.log(`Agent login request for ${body.agentId}`);
|
||||
|
||||
try {
|
||||
const result = await this.ozonetelAgent.changeAgentState({
|
||||
agentId: this.defaultAgentId,
|
||||
state: body.state,
|
||||
pauseReason: body.pauseReason,
|
||||
});
|
||||
return 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
|
||||
if (body.state === 'Ready') {
|
||||
try {
|
||||
const assigned = await this.missedQueue.assignNext(this.defaultAgentId);
|
||||
if (assigned) {
|
||||
return {
|
||||
status: 'ok',
|
||||
message: `State changed to Ready. Assigned missed call ${assigned.id}`,
|
||||
assignedCall: assigned,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Auto-assignment on Ready failed: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Post('agent-ready')
|
||||
async agentReady() {
|
||||
this.logger.log(
|
||||
`Force ready: logging out and back in agent ${this.defaultAgentId}`,
|
||||
);
|
||||
|
||||
try {
|
||||
await this.ozonetelAgent.logoutAgent({
|
||||
agentId: this.defaultAgentId,
|
||||
password: this.defaultAgentPassword,
|
||||
});
|
||||
const result = await this.ozonetelAgent.loginAgent({
|
||||
agentId: this.defaultAgentId,
|
||||
password: this.defaultAgentPassword,
|
||||
phoneNumber: this.defaultSipId,
|
||||
mode: 'blended',
|
||||
});
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
const message =
|
||||
error.response?.data?.message ?? error.message ?? 'Force ready failed';
|
||||
this.logger.error(`Force ready failed: ${message}`);
|
||||
throw new HttpException(message, error.response?.status ?? 502);
|
||||
}
|
||||
}
|
||||
|
||||
@Post('dispose')
|
||||
async dispose(
|
||||
@Body()
|
||||
body: {
|
||||
ucid: string;
|
||||
disposition: string;
|
||||
callerPhone?: string;
|
||||
direction?: string;
|
||||
durationSec?: number;
|
||||
leadId?: string;
|
||||
notes?: string;
|
||||
missedCallId?: string;
|
||||
},
|
||||
) {
|
||||
if (!body.ucid || !body.disposition) {
|
||||
throw new HttpException('ucid and disposition required', 400);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Dispose: ucid=${body.ucid} disposition=${body.disposition}`,
|
||||
);
|
||||
|
||||
const ozonetelDisposition = this.mapToOzonetelDisposition(body.disposition);
|
||||
|
||||
try {
|
||||
const result = await this.ozonetelAgent.setDisposition({
|
||||
agentId: this.defaultAgentId,
|
||||
ucid: body.ucid,
|
||||
disposition: ozonetelDisposition,
|
||||
});
|
||||
} catch (error: any) {
|
||||
const message =
|
||||
error.response?.data?.message ?? error.message ?? 'Disposition failed';
|
||||
this.logger.error(`Dispose failed: ${message}`);
|
||||
}
|
||||
|
||||
// Handle missed call callback status update
|
||||
if (body.missedCallId) {
|
||||
const statusMap: Record<string, string> = {
|
||||
APPOINTMENT_BOOKED: 'CALLBACK_COMPLETED',
|
||||
INFO_PROVIDED: 'CALLBACK_COMPLETED',
|
||||
FOLLOW_UP_SCHEDULED: 'CALLBACK_COMPLETED',
|
||||
CALLBACK_REQUESTED: 'CALLBACK_COMPLETED',
|
||||
WRONG_NUMBER: 'WRONG_NUMBER',
|
||||
};
|
||||
const newStatus = statusMap[body.disposition];
|
||||
if (newStatus) {
|
||||
try {
|
||||
await this.platform.query<any>(
|
||||
`mutation { updateCall(id: "${body.missedCallId}", data: { callbackstatus: ${newStatus} }) { id } }`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to update missed call status: ${err}`);
|
||||
const result = await this.ozonetelAgent.loginAgent(body);
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
throw new HttpException(
|
||||
error.response?.data?.message ?? 'Agent login failed',
|
||||
error.response?.status ?? 500,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-assign next missed call to this agent
|
||||
try {
|
||||
await this.missedQueue.assignNext(this.defaultAgentId);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Auto-assignment after dispose failed: ${err}`);
|
||||
@Post('agent-logout')
|
||||
async agentLogout(
|
||||
@Body() body: { agentId: string; password: string },
|
||||
) {
|
||||
this.logger.log(`Agent logout request for ${body.agentId}`);
|
||||
|
||||
try {
|
||||
const result = await this.ozonetelAgent.logoutAgent(body);
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
throw new HttpException(
|
||||
error.response?.data?.message ?? 'Agent logout failed',
|
||||
error.response?.status ?? 500,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { status: 'ok' };
|
||||
}
|
||||
@Post('agent-state')
|
||||
async agentState(
|
||||
@Body() body: { state: 'Ready' | 'Pause'; pauseReason?: string },
|
||||
) {
|
||||
if (!body.state) {
|
||||
throw new HttpException('state required', 400);
|
||||
}
|
||||
|
||||
@Post('dial')
|
||||
async dial(
|
||||
@Body()
|
||||
body: {
|
||||
phoneNumber: string;
|
||||
campaignName?: string;
|
||||
leadId?: string;
|
||||
},
|
||||
) {
|
||||
if (!body.phoneNumber) {
|
||||
throw new HttpException('phoneNumber required', 400);
|
||||
this.logger.log(`[AGENT-STATE] ${this.defaultAgentId} → ${body.state} (${body.pauseReason ?? 'none'})`);
|
||||
|
||||
try {
|
||||
const result = await this.ozonetelAgent.changeAgentState({
|
||||
agentId: this.defaultAgentId,
|
||||
state: body.state,
|
||||
pauseReason: body.pauseReason,
|
||||
});
|
||||
this.logger.log(`[AGENT-STATE] Ozonetel response: ${JSON.stringify(result)}`);
|
||||
|
||||
// Auto-assign missed call when agent goes Ready
|
||||
if (body.state === 'Ready') {
|
||||
try {
|
||||
const assigned = await this.missedQueue.assignNext(this.defaultAgentId);
|
||||
if (assigned) {
|
||||
this.logger.log(`[AGENT-STATE] Auto-assigned missed call ${assigned.id}`);
|
||||
return { ...result, assignedCall: assigned };
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`[AGENT-STATE] Auto-assignment on Ready failed: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
const message = error.response?.data?.message ?? error.message ?? 'State change failed';
|
||||
const responseData = error.response?.data ? JSON.stringify(error.response.data) : '';
|
||||
this.logger.error(`[AGENT-STATE] FAILED: ${message} ${responseData}`);
|
||||
return { status: 'error', message };
|
||||
}
|
||||
}
|
||||
|
||||
const campaignName =
|
||||
body.campaignName ??
|
||||
process.env.OZONETEL_CAMPAIGN_NAME ??
|
||||
'Inbound_918041763265';
|
||||
// force-ready moved to /api/maint/force-ready
|
||||
|
||||
this.logger.log(
|
||||
`Manual dial: ${body.phoneNumber} campaign=${campaignName} (lead: ${body.leadId ?? 'none'})`,
|
||||
);
|
||||
@Post('dispose')
|
||||
async dispose(
|
||||
@Body() body: {
|
||||
ucid: string;
|
||||
disposition: string;
|
||||
callerPhone?: string;
|
||||
direction?: string;
|
||||
durationSec?: number;
|
||||
leadId?: string;
|
||||
notes?: string;
|
||||
missedCallId?: string;
|
||||
},
|
||||
) {
|
||||
if (!body.ucid || !body.disposition) {
|
||||
throw new HttpException('ucid and disposition required', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.ozonetelAgent.manualDial({
|
||||
agentId: this.defaultAgentId,
|
||||
campaignName,
|
||||
customerNumber: body.phoneNumber,
|
||||
});
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
const message =
|
||||
error.response?.data?.message ?? error.message ?? 'Dial failed';
|
||||
throw new HttpException(message, error.response?.status ?? 502);
|
||||
}
|
||||
}
|
||||
const ozonetelDisposition = this.mapToOzonetelDisposition(body.disposition);
|
||||
|
||||
@Post('call-control')
|
||||
async callControl(
|
||||
@Body()
|
||||
body: {
|
||||
action:
|
||||
| 'CONFERENCE'
|
||||
| 'HOLD'
|
||||
| 'UNHOLD'
|
||||
| 'MUTE'
|
||||
| 'UNMUTE'
|
||||
| 'KICK_CALL';
|
||||
ucid: string;
|
||||
conferenceNumber?: string;
|
||||
},
|
||||
) {
|
||||
if (!body.action || !body.ucid) {
|
||||
throw new HttpException('action and ucid required', 400);
|
||||
}
|
||||
if (body.action === 'CONFERENCE' && !body.conferenceNumber) {
|
||||
throw new HttpException(
|
||||
'conferenceNumber required for CONFERENCE action',
|
||||
400,
|
||||
);
|
||||
this.logger.log(`[DISPOSE] ucid=${body.ucid} disposition=${body.disposition} → ozonetel="${ozonetelDisposition}" agentId=${this.defaultAgentId} callerPhone=${body.callerPhone ?? 'none'} direction=${body.direction ?? 'unknown'} leadId=${body.leadId ?? 'none'}`);
|
||||
|
||||
try {
|
||||
const result = await this.ozonetelAgent.setDisposition({
|
||||
agentId: this.defaultAgentId,
|
||||
ucid: body.ucid,
|
||||
disposition: ozonetelDisposition,
|
||||
});
|
||||
this.logger.log(`[DISPOSE] Ozonetel response: ${JSON.stringify(result)}`);
|
||||
} catch (error: any) {
|
||||
const message = error.response?.data?.message ?? error.message ?? 'Disposition failed';
|
||||
const responseData = error.response?.data ? JSON.stringify(error.response.data) : '';
|
||||
this.logger.error(`[DISPOSE] FAILED: ${message} ${responseData}`);
|
||||
}
|
||||
|
||||
// Handle missed call callback status update
|
||||
if (body.missedCallId) {
|
||||
const statusMap: Record<string, string> = {
|
||||
APPOINTMENT_BOOKED: 'CALLBACK_COMPLETED',
|
||||
INFO_PROVIDED: 'CALLBACK_COMPLETED',
|
||||
FOLLOW_UP_SCHEDULED: 'CALLBACK_COMPLETED',
|
||||
CALLBACK_REQUESTED: 'CALLBACK_COMPLETED',
|
||||
WRONG_NUMBER: 'WRONG_NUMBER',
|
||||
};
|
||||
const newStatus = statusMap[body.disposition];
|
||||
if (newStatus) {
|
||||
try {
|
||||
await this.platform.query<any>(
|
||||
`mutation { updateCall(id: "${body.missedCallId}", data: { callbackstatus: ${newStatus} }) { id } }`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to update missed call status: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-assign next missed call to this agent
|
||||
try {
|
||||
await this.missedQueue.assignNext(this.defaultAgentId);
|
||||
} catch (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' };
|
||||
}
|
||||
|
||||
this.logger.log(`Call control: ${body.action} ucid=${body.ucid}`);
|
||||
@Post('dial')
|
||||
async dial(
|
||||
@Body() body: { phoneNumber: string; campaignName?: string; leadId?: string },
|
||||
) {
|
||||
if (!body.phoneNumber) {
|
||||
throw new HttpException('phoneNumber required', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.ozonetelAgent.callControl(body);
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
const message =
|
||||
error.response?.data?.message ?? error.message ?? 'Call control failed';
|
||||
throw new HttpException(message, error.response?.status ?? 502);
|
||||
}
|
||||
}
|
||||
const campaignName = body.campaignName ?? process.env.OZONETEL_CAMPAIGN_NAME ?? 'Inbound_918041763265';
|
||||
|
||||
@Post('recording')
|
||||
async recording(@Body() body: { ucid: string; action: 'pause' | 'unPause' }) {
|
||||
if (!body.ucid || !body.action) {
|
||||
throw new HttpException('ucid and action required', 400);
|
||||
this.logger.log(`[DIAL] phone=${body.phoneNumber} campaign=${campaignName} agentId=${this.defaultAgentId} lead=${body.leadId ?? 'none'}`);
|
||||
|
||||
try {
|
||||
const result = await this.ozonetelAgent.manualDial({
|
||||
agentId: this.defaultAgentId,
|
||||
campaignName,
|
||||
customerNumber: body.phoneNumber,
|
||||
});
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
const message = error.response?.data?.message ?? error.message ?? 'Dial failed';
|
||||
throw new HttpException(message, error.response?.status ?? 502);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.ozonetelAgent.pauseRecording(body);
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
const message =
|
||||
error.response?.data?.message ??
|
||||
error.message ??
|
||||
'Recording control failed';
|
||||
throw new HttpException(message, error.response?.status ?? 502);
|
||||
}
|
||||
}
|
||||
@Post('call-control')
|
||||
async callControl(
|
||||
@Body() body: {
|
||||
action: 'CONFERENCE' | 'HOLD' | 'UNHOLD' | 'MUTE' | 'UNMUTE' | 'KICK_CALL';
|
||||
ucid: string;
|
||||
conferenceNumber?: string;
|
||||
},
|
||||
) {
|
||||
if (!body.action || !body.ucid) {
|
||||
throw new HttpException('action and ucid required', 400);
|
||||
}
|
||||
if (body.action === 'CONFERENCE' && !body.conferenceNumber) {
|
||||
throw new HttpException('conferenceNumber required for CONFERENCE action', 400);
|
||||
}
|
||||
|
||||
@Get('missed-calls')
|
||||
async missedCalls() {
|
||||
const result = await this.ozonetelAgent.getAbandonCalls();
|
||||
return result;
|
||||
}
|
||||
this.logger.log(`Call control: ${body.action} ucid=${body.ucid}`);
|
||||
|
||||
@Get('call-history')
|
||||
async callHistory(
|
||||
@Query('date') date?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('callType') callType?: string,
|
||||
) {
|
||||
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
||||
this.logger.log(
|
||||
`Call history: date=${targetDate} status=${status ?? 'all'} type=${callType ?? 'all'}`,
|
||||
);
|
||||
|
||||
const result = await this.ozonetelAgent.fetchCDR({
|
||||
date: targetDate,
|
||||
status,
|
||||
callType,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get('performance')
|
||||
async performance(@Query('date') date?: string) {
|
||||
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
||||
this.logger.log(
|
||||
`Performance: date=${targetDate} agent=${this.defaultAgentId}`,
|
||||
);
|
||||
|
||||
const [cdr, summary, aht] = await Promise.all([
|
||||
this.ozonetelAgent.fetchCDR({ date: targetDate }),
|
||||
this.ozonetelAgent.getAgentSummary(this.defaultAgentId, targetDate),
|
||||
this.ozonetelAgent.getAHT(this.defaultAgentId),
|
||||
]);
|
||||
|
||||
const totalCalls = cdr.length;
|
||||
const inbound = cdr.filter((c: any) => c.Type === 'InBound').length;
|
||||
const outbound = cdr.filter(
|
||||
(c: any) => c.Type === 'Manual' || c.Type === 'Progressive',
|
||||
).length;
|
||||
const answered = cdr.filter((c: any) => c.Status === 'Answered').length;
|
||||
const missed = cdr.filter(
|
||||
(c: any) => c.Status === 'Unanswered' || c.Status === 'NotAnswered',
|
||||
).length;
|
||||
|
||||
const talkTimes = cdr
|
||||
.filter((c: any) => c.TalkTime && c.TalkTime !== '00:00:00')
|
||||
.map((c: any) => {
|
||||
const parts = c.TalkTime.split(':').map(Number);
|
||||
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
});
|
||||
const avgTalkTimeSec =
|
||||
talkTimes.length > 0
|
||||
? Math.round(
|
||||
talkTimes.reduce((a: number, b: number) => a + b, 0) /
|
||||
talkTimes.length,
|
||||
)
|
||||
: 0;
|
||||
|
||||
const dispositions: Record<string, number> = {};
|
||||
for (const c of cdr) {
|
||||
const d = (c as any).Disposition || 'No Disposition';
|
||||
dispositions[d] = (dispositions[d] ?? 0) + 1;
|
||||
try {
|
||||
const result = await this.ozonetelAgent.callControl(body);
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
const message = error.response?.data?.message ?? error.message ?? 'Call control failed';
|
||||
throw new HttpException(message, error.response?.status ?? 502);
|
||||
}
|
||||
}
|
||||
|
||||
const appointmentsBooked = cdr.filter((c: any) =>
|
||||
c.Disposition?.toLowerCase().includes('appointment'),
|
||||
).length;
|
||||
@Post('recording')
|
||||
async recording(
|
||||
@Body() body: { ucid: string; action: 'pause' | 'unPause' },
|
||||
) {
|
||||
if (!body.ucid || !body.action) {
|
||||
throw new HttpException('ucid and action required', 400);
|
||||
}
|
||||
|
||||
return {
|
||||
date: targetDate,
|
||||
calls: { total: totalCalls, inbound, outbound, answered, missed },
|
||||
avgTalkTimeSec,
|
||||
avgHandlingTime: aht,
|
||||
conversionRate:
|
||||
totalCalls > 0
|
||||
? Math.round((appointmentsBooked / totalCalls) * 100)
|
||||
: 0,
|
||||
appointmentsBooked,
|
||||
timeUtilization: summary,
|
||||
dispositions,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const result = await this.ozonetelAgent.pauseRecording(body);
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
const message = error.response?.data?.message ?? error.message ?? 'Recording control failed';
|
||||
throw new HttpException(message, error.response?.status ?? 502);
|
||||
}
|
||||
}
|
||||
|
||||
private mapToOzonetelDisposition(disposition: string): string {
|
||||
// Campaign only has 'General Enquiry' configured currently
|
||||
const map: Record<string, string> = {
|
||||
APPOINTMENT_BOOKED: 'General Enquiry',
|
||||
FOLLOW_UP_SCHEDULED: 'General Enquiry',
|
||||
INFO_PROVIDED: 'General Enquiry',
|
||||
NO_ANSWER: 'General Enquiry',
|
||||
WRONG_NUMBER: 'General Enquiry',
|
||||
CALLBACK_REQUESTED: 'General Enquiry',
|
||||
};
|
||||
return map[disposition] ?? 'General Enquiry';
|
||||
}
|
||||
@Get('missed-calls')
|
||||
async missedCalls() {
|
||||
const result = await this.ozonetelAgent.getAbandonCalls();
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get('call-history')
|
||||
async callHistory(
|
||||
@Query('date') date?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('callType') callType?: string,
|
||||
) {
|
||||
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
||||
this.logger.log(`Call history: date=${targetDate} status=${status ?? 'all'} type=${callType ?? 'all'}`);
|
||||
|
||||
const result = await this.ozonetelAgent.fetchCDR({
|
||||
date: targetDate,
|
||||
status,
|
||||
callType,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get('performance')
|
||||
async performance(@Query('date') date?: string) {
|
||||
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
||||
this.logger.log(`Performance: date=${targetDate} agent=${this.defaultAgentId}`);
|
||||
|
||||
const [cdr, summary, aht] = await Promise.all([
|
||||
this.ozonetelAgent.fetchCDR({ date: targetDate }),
|
||||
this.ozonetelAgent.getAgentSummary(this.defaultAgentId, targetDate),
|
||||
this.ozonetelAgent.getAHT(this.defaultAgentId),
|
||||
]);
|
||||
|
||||
const totalCalls = cdr.length;
|
||||
const inbound = cdr.filter((c: any) => c.Type === 'InBound').length;
|
||||
const outbound = cdr.filter((c: any) => c.Type === 'Manual' || c.Type === 'Progressive').length;
|
||||
const answered = cdr.filter((c: any) => c.Status === 'Answered').length;
|
||||
const missed = cdr.filter((c: any) => c.Status === 'Unanswered' || c.Status === 'NotAnswered').length;
|
||||
|
||||
const talkTimes = cdr
|
||||
.filter((c: any) => c.TalkTime && c.TalkTime !== '00:00:00')
|
||||
.map((c: any) => {
|
||||
const parts = c.TalkTime.split(':').map(Number);
|
||||
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
});
|
||||
const avgTalkTimeSec = talkTimes.length > 0
|
||||
? Math.round(talkTimes.reduce((a: number, b: number) => a + b, 0) / talkTimes.length)
|
||||
: 0;
|
||||
|
||||
const dispositions: Record<string, number> = {};
|
||||
for (const c of cdr) {
|
||||
const d = (c as any).Disposition || 'No Disposition';
|
||||
dispositions[d] = (dispositions[d] ?? 0) + 1;
|
||||
}
|
||||
|
||||
const appointmentsBooked = cdr.filter((c: any) =>
|
||||
c.Disposition?.toLowerCase().includes('appointment'),
|
||||
).length;
|
||||
|
||||
return {
|
||||
date: targetDate,
|
||||
calls: { total: totalCalls, inbound, outbound, answered, missed },
|
||||
avgTalkTimeSec,
|
||||
avgHandlingTime: aht,
|
||||
conversionRate: totalCalls > 0 ? Math.round((appointmentsBooked / totalCalls) * 100) : 0,
|
||||
appointmentsBooked,
|
||||
timeUtilization: summary,
|
||||
dispositions,
|
||||
};
|
||||
}
|
||||
|
||||
private mapToOzonetelDisposition(disposition: string): string {
|
||||
// Campaign only has 'General Enquiry' configured currently
|
||||
const map: Record<string, string> = {
|
||||
'APPOINTMENT_BOOKED': 'General Enquiry',
|
||||
'FOLLOW_UP_SCHEDULED': 'General Enquiry',
|
||||
'INFO_PROVIDED': 'General Enquiry',
|
||||
'NO_ANSWER': 'General Enquiry',
|
||||
'WRONG_NUMBER': 'General Enquiry',
|
||||
'CALLBACK_REQUESTED': 'General Enquiry',
|
||||
};
|
||||
return map[disposition] ?? 'General Enquiry';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,84 +1,85 @@
|
||||
export type LeadNode = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
contactName: { firstName: string; lastName: string } | null;
|
||||
contactPhone: { number: string; callingCode: string }[] | null;
|
||||
contactEmail: { address: string }[] | null;
|
||||
leadSource: string | null;
|
||||
leadStatus: string | null;
|
||||
interestedService: string | null;
|
||||
assignedAgent: string | null;
|
||||
campaignId: string | null;
|
||||
adId: string | null;
|
||||
contactAttempts: number | null;
|
||||
spamScore: number | null;
|
||||
isSpam: boolean | null;
|
||||
aiSummary: string | null;
|
||||
aiSuggestedAction: string | null;
|
||||
id: string;
|
||||
createdAt: string;
|
||||
contactName: { firstName: string; lastName: string } | null;
|
||||
contactPhone: { number: string; callingCode: string }[] | null;
|
||||
contactEmail: { address: string }[] | null;
|
||||
leadSource: string | null;
|
||||
leadStatus: string | null;
|
||||
interestedService: string | null;
|
||||
assignedAgent: string | null;
|
||||
campaignId: string | null;
|
||||
adId: string | null;
|
||||
contactAttempts: number | null;
|
||||
spamScore: number | null;
|
||||
isSpam: boolean | null;
|
||||
aiSummary: string | null;
|
||||
aiSuggestedAction: string | null;
|
||||
};
|
||||
|
||||
export type LeadActivityNode = {
|
||||
id: string;
|
||||
activityType: string | null;
|
||||
summary: string | null;
|
||||
occurredAt: string | null;
|
||||
performedBy: string | null;
|
||||
channel: string | null;
|
||||
id: string;
|
||||
activityType: string | null;
|
||||
summary: string | null;
|
||||
occurredAt: string | null;
|
||||
performedBy: string | null;
|
||||
channel: string | null;
|
||||
};
|
||||
|
||||
export type CallNode = {
|
||||
id: string;
|
||||
callDirection: string | null;
|
||||
callStatus: string | null;
|
||||
disposition: string | null;
|
||||
agentName: string | null;
|
||||
startedAt: string | null;
|
||||
endedAt: string | null;
|
||||
durationSeconds: number | null;
|
||||
leadId: string | null;
|
||||
id: string;
|
||||
callDirection: string | null;
|
||||
callStatus: string | null;
|
||||
disposition: string | null;
|
||||
agentName: string | null;
|
||||
startedAt: string | null;
|
||||
endedAt: string | null;
|
||||
durationSeconds: number | null;
|
||||
leadId: string | null;
|
||||
};
|
||||
|
||||
export type CreateCallInput = {
|
||||
callDirection: string;
|
||||
callStatus: string;
|
||||
callerNumber?: { number: string; callingCode: string }[];
|
||||
agentName?: string;
|
||||
startedAt?: string;
|
||||
endedAt?: string;
|
||||
durationSeconds?: number;
|
||||
disposition?: string;
|
||||
callNotes?: string;
|
||||
leadId?: string;
|
||||
callDirection: string;
|
||||
callStatus: string;
|
||||
callerNumber?: { number: string; callingCode: string }[];
|
||||
agentName?: string;
|
||||
startedAt?: string;
|
||||
endedAt?: string;
|
||||
durationSeconds?: number;
|
||||
disposition?: string;
|
||||
callNotes?: string;
|
||||
leadId?: string;
|
||||
sla?: number;
|
||||
};
|
||||
|
||||
export type CreateLeadActivityInput = {
|
||||
activityType: string;
|
||||
summary: string;
|
||||
occurredAt: string;
|
||||
performedBy: string;
|
||||
channel: string;
|
||||
durationSeconds?: number;
|
||||
outcome?: string;
|
||||
leadId: string;
|
||||
activityType: string;
|
||||
summary: string;
|
||||
occurredAt: string;
|
||||
performedBy: string;
|
||||
channel: string;
|
||||
durationSeconds?: number;
|
||||
outcome?: string;
|
||||
leadId: string;
|
||||
};
|
||||
|
||||
export type CreateLeadInput = {
|
||||
name: string;
|
||||
contactName?: { firstName: string; lastName?: string };
|
||||
contactPhone?: { primaryPhoneNumber: string };
|
||||
contactEmail?: { primaryEmailAddress: string };
|
||||
source?: string;
|
||||
status?: string;
|
||||
interestedService?: string;
|
||||
assignedAgent?: string;
|
||||
campaignId?: string;
|
||||
notes?: string;
|
||||
name: string;
|
||||
contactName?: { firstName: string; lastName?: string };
|
||||
contactPhone?: { primaryPhoneNumber: string };
|
||||
contactEmail?: { primaryEmailAddress: string };
|
||||
source?: string;
|
||||
status?: string;
|
||||
interestedService?: string;
|
||||
assignedAgent?: string;
|
||||
campaignId?: string;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
export type UpdateLeadInput = {
|
||||
leadStatus?: string;
|
||||
lastContactedAt?: string;
|
||||
aiSummary?: string;
|
||||
aiSuggestedAction?: string;
|
||||
contactAttempts?: number;
|
||||
leadStatus?: string;
|
||||
lastContactedAt?: string;
|
||||
aiSummary?: string;
|
||||
aiSuggestedAction?: string;
|
||||
contactAttempts?: number;
|
||||
};
|
||||
|
||||
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,41 +1,55 @@
|
||||
import { Controller, Get, Post, Body, Query, Logger } from '@nestjs/common';
|
||||
import { Controller, Get, Post, Body, Query, Sse, Logger } from '@nestjs/common';
|
||||
import { Observable, filter, map } from 'rxjs';
|
||||
import { SupervisorService } from './supervisor.service';
|
||||
|
||||
@Controller('api/supervisor')
|
||||
export class SupervisorController {
|
||||
private readonly logger = new Logger(SupervisorController.name);
|
||||
private readonly logger = new Logger(SupervisorController.name);
|
||||
|
||||
constructor(private readonly supervisor: SupervisorService) {}
|
||||
constructor(private readonly supervisor: SupervisorService) {}
|
||||
|
||||
@Get('active-calls')
|
||||
getActiveCalls() {
|
||||
return this.supervisor.getActiveCalls();
|
||||
}
|
||||
@Get('active-calls')
|
||||
getActiveCalls() {
|
||||
return this.supervisor.getActiveCalls();
|
||||
}
|
||||
|
||||
@Get('team-performance')
|
||||
async getTeamPerformance(@Query('date') date?: string) {
|
||||
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
||||
this.logger.log(`Team performance: date=${targetDate}`);
|
||||
return this.supervisor.getTeamPerformance(targetDate);
|
||||
}
|
||||
@Get('team-performance')
|
||||
async getTeamPerformance(@Query('date') date?: string) {
|
||||
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
||||
this.logger.log(`Team performance: date=${targetDate}`);
|
||||
return this.supervisor.getTeamPerformance(targetDate);
|
||||
}
|
||||
|
||||
@Post('call-event')
|
||||
handleCallEvent(@Body() body: any) {
|
||||
const event = body.data ?? body;
|
||||
this.logger.log(
|
||||
`Call event: ${event.action} ucid=${event.ucid ?? event.monitorUCID} agent=${event.agent_id ?? event.agentID}`,
|
||||
);
|
||||
this.supervisor.handleCallEvent(event);
|
||||
return { received: true };
|
||||
}
|
||||
@Post('call-event')
|
||||
handleCallEvent(@Body() body: any) {
|
||||
const event = body.data ?? body;
|
||||
this.logger.log(`[CALL-EVENT] ${JSON.stringify(event)}`);
|
||||
this.supervisor.handleCallEvent(event);
|
||||
return { received: true };
|
||||
}
|
||||
|
||||
@Post('agent-event')
|
||||
handleAgentEvent(@Body() body: any) {
|
||||
const event = body.data ?? body;
|
||||
this.logger.log(
|
||||
`Agent event: ${event.action} agent=${event.agentId ?? event.agent_id}`,
|
||||
);
|
||||
this.supervisor.handleAgentEvent(event);
|
||||
return { received: true };
|
||||
}
|
||||
@Post('agent-event')
|
||||
handleAgentEvent(@Body() body: any) {
|
||||
const event = body.data ?? body;
|
||||
this.logger.log(`[AGENT-EVENT] ${JSON.stringify(event)}`);
|
||||
this.supervisor.handleAgentEvent(event);
|
||||
return { received: true };
|
||||
}
|
||||
|
||||
@Get('agent-state')
|
||||
getAgentState(@Query('agentId') agentId: string) {
|
||||
const state = this.supervisor.getAgentState(agentId);
|
||||
return state ?? { state: 'offline', timestamp: null };
|
||||
}
|
||||
|
||||
@Sse('agent-state/stream')
|
||||
streamAgentState(@Query('agentId') agentId: string): Observable<MessageEvent> {
|
||||
this.logger.log(`[SSE] Agent state stream opened for ${agentId}`);
|
||||
return this.supervisor.agentStateSubject.pipe(
|
||||
filter(event => event.agentId === agentId),
|
||||
map(event => ({
|
||||
data: JSON.stringify({ state: event.state, timestamp: event.timestamp }),
|
||||
} as MessageEvent)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@ import { SupervisorController } from './supervisor.controller';
|
||||
import { SupervisorService } from './supervisor.service';
|
||||
|
||||
@Module({
|
||||
imports: [PlatformModule, OzonetelAgentModule],
|
||||
controllers: [SupervisorController],
|
||||
providers: [SupervisorService],
|
||||
imports: [PlatformModule, OzonetelAgentModule],
|
||||
controllers: [SupervisorController],
|
||||
providers: [SupervisorService],
|
||||
exports: [SupervisorService],
|
||||
})
|
||||
export class SupervisorModule {}
|
||||
|
||||
@@ -1,98 +1,136 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Subject } from 'rxjs';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
||||
|
||||
type ActiveCall = {
|
||||
ucid: string;
|
||||
agentId: string;
|
||||
callerNumber: string;
|
||||
callType: string;
|
||||
startTime: string;
|
||||
status: 'active' | 'on-hold';
|
||||
ucid: string;
|
||||
agentId: string;
|
||||
callerNumber: string;
|
||||
callType: string;
|
||||
startTime: string;
|
||||
status: 'active' | 'on-hold';
|
||||
};
|
||||
|
||||
export type AgentOzonetelState = 'ready' | 'break' | 'training' | 'calling' | 'in-call' | 'acw' | 'offline';
|
||||
|
||||
type AgentStateEntry = {
|
||||
state: AgentOzonetelState;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SupervisorService implements OnModuleInit {
|
||||
private readonly logger = new Logger(SupervisorService.name);
|
||||
private readonly activeCalls = new Map<string, ActiveCall>();
|
||||
private readonly logger = new Logger(SupervisorService.name);
|
||||
private readonly activeCalls = new Map<string, ActiveCall>();
|
||||
private readonly agentStates = new Map<string, AgentStateEntry>();
|
||||
readonly agentStateSubject = new Subject<{ agentId: string; state: AgentOzonetelState; timestamp: string }>();
|
||||
|
||||
constructor(
|
||||
private platform: PlatformGraphqlService,
|
||||
private ozonetel: OzonetelAgentService,
|
||||
private config: ConfigService,
|
||||
) {}
|
||||
constructor(
|
||||
private platform: PlatformGraphqlService,
|
||||
private ozonetel: OzonetelAgentService,
|
||||
private config: ConfigService,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
this.logger.log('Supervisor service initialized');
|
||||
}
|
||||
|
||||
handleCallEvent(event: any) {
|
||||
const action = event.action;
|
||||
const ucid = event.ucid ?? event.monitorUCID;
|
||||
const agentId = event.agent_id ?? event.agentID;
|
||||
const callerNumber = event.caller_id ?? event.callerID;
|
||||
const callType = event.call_type ?? event.Type;
|
||||
const eventTime =
|
||||
event.event_time ?? event.eventTime ?? new Date().toISOString();
|
||||
|
||||
if (!ucid) return;
|
||||
|
||||
if (action === 'Answered' || action === 'Calling') {
|
||||
this.activeCalls.set(ucid, {
|
||||
ucid,
|
||||
agentId,
|
||||
callerNumber,
|
||||
callType,
|
||||
startTime: eventTime,
|
||||
status: 'active',
|
||||
});
|
||||
this.logger.log(`Active call: ${agentId} ↔ ${callerNumber} (${ucid})`);
|
||||
} else if (action === 'Disconnect') {
|
||||
this.activeCalls.delete(ucid);
|
||||
this.logger.log(`Call ended: ${ucid}`);
|
||||
async onModuleInit() {
|
||||
this.logger.log('Supervisor service initialized');
|
||||
}
|
||||
}
|
||||
|
||||
handleAgentEvent(event: any) {
|
||||
this.logger.log(
|
||||
`Agent event: ${event.agentId ?? event.agent_id} → ${event.action}`,
|
||||
);
|
||||
}
|
||||
handleCallEvent(event: any) {
|
||||
const action = event.action;
|
||||
const ucid = event.ucid ?? event.monitorUCID;
|
||||
const agentId = event.agent_id ?? event.agentID;
|
||||
const callerNumber = event.caller_id ?? event.callerID;
|
||||
const callType = event.call_type ?? event.Type;
|
||||
const eventTime = event.event_time ?? event.eventTime ?? new Date().toISOString();
|
||||
|
||||
getActiveCalls(): ActiveCall[] {
|
||||
return Array.from(this.activeCalls.values());
|
||||
}
|
||||
if (!ucid) return;
|
||||
|
||||
async getTeamPerformance(date: string): Promise<any> {
|
||||
// Get all agents from platform
|
||||
const agentData = await this.platform.query<any>(
|
||||
`{ agents(first: 20) { edges { node {
|
||||
if (action === 'Answered' || action === 'Calling') {
|
||||
this.activeCalls.set(ucid, {
|
||||
ucid, agentId, callerNumber,
|
||||
callType, startTime: eventTime, status: 'active',
|
||||
});
|
||||
this.logger.log(`Active call: ${agentId} ↔ ${callerNumber} (${ucid})`);
|
||||
} else if (action === 'Disconnect') {
|
||||
this.activeCalls.delete(ucid);
|
||||
this.logger.log(`Call ended: ${ucid}`);
|
||||
}
|
||||
}
|
||||
|
||||
handleAgentEvent(event: any) {
|
||||
const agentId = event.agentId ?? event.agent_id ?? 'unknown';
|
||||
const action = event.action ?? 'unknown';
|
||||
const eventData = event.eventData ?? '';
|
||||
const eventTime = event.event_time ?? event.eventTime ?? new Date().toISOString();
|
||||
this.logger.log(`[AGENT-STATE] ${agentId} → ${action}${eventData ? ` (${eventData})` : ''} at ${eventTime}`);
|
||||
|
||||
const mapped = this.mapOzonetelAction(action, eventData);
|
||||
if (mapped) {
|
||||
this.agentStates.set(agentId, { state: mapped, timestamp: eventTime });
|
||||
this.agentStateSubject.next({ agentId, state: mapped, timestamp: eventTime });
|
||||
this.logger.log(`[AGENT-STATE] Emitted: ${agentId} → ${mapped}`);
|
||||
}
|
||||
}
|
||||
|
||||
private mapOzonetelAction(action: string, eventData: string): AgentOzonetelState | null {
|
||||
switch (action) {
|
||||
case 'release': return 'ready';
|
||||
case 'IDLE': return 'ready'; // agent available after unanswered/canceled call
|
||||
case 'calling': return 'calling';
|
||||
case 'incall': return 'in-call';
|
||||
case 'ACW': return 'acw';
|
||||
case 'logout': return 'offline';
|
||||
case 'AUX':
|
||||
// "changeMode" is the brief AUX during login — not a real pause
|
||||
if (eventData === 'changeMode') return null;
|
||||
if (eventData?.toLowerCase().includes('training')) return 'training';
|
||||
return 'break';
|
||||
case 'login': return null; // wait for release
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
getAgentState(agentId: string): AgentStateEntry | null {
|
||||
return this.agentStates.get(agentId) ?? null;
|
||||
}
|
||||
|
||||
emitForceLogout(agentId: string) {
|
||||
this.logger.log(`[AGENT-STATE] Emitting force-logout for ${agentId}`);
|
||||
this.agentStates.set(agentId, { state: 'offline', timestamp: new Date().toISOString() });
|
||||
// Use a special state so frontend can distinguish admin force-logout from normal Ozonetel logout
|
||||
this.agentStateSubject.next({ agentId, state: 'force-logout' as any, timestamp: new Date().toISOString() });
|
||||
}
|
||||
|
||||
getActiveCalls(): ActiveCall[] {
|
||||
return Array.from(this.activeCalls.values());
|
||||
}
|
||||
|
||||
async getTeamPerformance(date: string): Promise<any> {
|
||||
// Get all agents from platform
|
||||
const agentData = await this.platform.query<any>(
|
||||
`{ agents(first: 20) { edges { node {
|
||||
id name ozonetelagentid npsscore
|
||||
maxidleminutes minnpsthreshold minconversionpercent
|
||||
} } } }`,
|
||||
);
|
||||
const agents = agentData?.agents?.edges?.map((e: any) => e.node) ?? [];
|
||||
);
|
||||
const agents = agentData?.agents?.edges?.map((e: any) => e.node) ?? [];
|
||||
|
||||
// Fetch Ozonetel time summary per agent
|
||||
const summaries = await Promise.all(
|
||||
agents.map(async (agent: any) => {
|
||||
if (!agent.ozonetelagentid) return { ...agent, timeBreakdown: null };
|
||||
try {
|
||||
const summary = await this.ozonetel.getAgentSummary(
|
||||
agent.ozonetelagentid,
|
||||
date,
|
||||
);
|
||||
return { ...agent, timeBreakdown: summary };
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Failed to get summary for ${agent.ozonetelagentid}: ${err}`,
|
||||
);
|
||||
return { ...agent, timeBreakdown: null };
|
||||
}
|
||||
}),
|
||||
);
|
||||
// Fetch Ozonetel time summary per agent
|
||||
const summaries = await Promise.all(
|
||||
agents.map(async (agent: any) => {
|
||||
if (!agent.ozonetelagentid) return { ...agent, timeBreakdown: null };
|
||||
try {
|
||||
const summary = await this.ozonetel.getAgentSummary(agent.ozonetelagentid, date);
|
||||
return { ...agent, timeBreakdown: summary };
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to get summary for ${agent.ozonetelagentid}: ${err}`);
|
||||
return { ...agent, timeBreakdown: null };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return { date, agents: summaries };
|
||||
}
|
||||
return { date, agents: summaries };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,287 +2,241 @@ import { Controller, Post, Body, Headers, Logger } from '@nestjs/common';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
// Ozonetel sends all timestamps in IST — convert to UTC for storage
|
||||
function istToUtc(istDateStr: string | null): string | null {
|
||||
if (!istDateStr) return null;
|
||||
// Parse as-is, then subtract 5:30 to get UTC
|
||||
const d = new Date(istDateStr);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
d.setMinutes(d.getMinutes() - 330); // IST is UTC+5:30
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
@Controller('webhooks/ozonetel')
|
||||
export class MissedCallWebhookController {
|
||||
private readonly logger = new Logger(MissedCallWebhookController.name);
|
||||
private readonly apiKey: string;
|
||||
private readonly logger = new Logger(MissedCallWebhookController.name);
|
||||
private readonly apiKey: string;
|
||||
|
||||
constructor(
|
||||
private readonly platform: PlatformGraphqlService,
|
||||
private readonly config: ConfigService,
|
||||
) {
|
||||
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||
}
|
||||
|
||||
@Post('missed-call')
|
||||
async handleCallWebhook(@Body() body: Record<string, any>) {
|
||||
// Ozonetel sends the payload as a JSON string inside a "data" field
|
||||
let payload: Record<string, any>;
|
||||
try {
|
||||
payload = typeof body.data === 'string' ? JSON.parse(body.data) : body;
|
||||
} catch {
|
||||
payload = body;
|
||||
constructor(
|
||||
private readonly platform: PlatformGraphqlService,
|
||||
private readonly config: ConfigService,
|
||||
) {
|
||||
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Call webhook: ${payload.CallerID} | ${payload.Status} | ${payload.Type}`,
|
||||
);
|
||||
@Post('missed-call')
|
||||
async handleCallWebhook(@Body() body: Record<string, any>) {
|
||||
// Ozonetel sends the payload as a JSON string inside a "data" field
|
||||
let payload: Record<string, any>;
|
||||
try {
|
||||
payload = typeof body.data === 'string' ? JSON.parse(body.data) : body;
|
||||
} catch {
|
||||
payload = body;
|
||||
}
|
||||
|
||||
const callerPhone = (payload.CallerID ?? '').replace(/^\+?91/, '');
|
||||
const status = payload.Status; // NotAnswered, Answered, Abandoned
|
||||
const type = payload.Type; // InBound, OutBound
|
||||
const startTime = payload.StartTime;
|
||||
const endTime = payload.EndTime;
|
||||
const duration = this.parseDuration(payload.CallDuration ?? '00:00:00');
|
||||
const agentName = payload.AgentName ?? null;
|
||||
const recordingUrl = payload.AudioFile ?? null;
|
||||
const ucid = payload.monitorUCID ?? null;
|
||||
const disposition = payload.Disposition ?? null;
|
||||
const hangupBy = payload.HangupBy ?? null;
|
||||
this.logger.log(`Call webhook: ${payload.CallerID} | ${payload.Status} | ${payload.Type}`);
|
||||
|
||||
if (!callerPhone) {
|
||||
this.logger.warn('No caller phone in webhook — skipping');
|
||||
return { received: true, processed: false };
|
||||
const callerPhone = (payload.CallerID ?? '').replace(/^\+?91/, '');
|
||||
const status = payload.Status; // NotAnswered, Answered, Abandoned
|
||||
const type = payload.Type; // InBound, OutBound
|
||||
const startTime = payload.StartTime;
|
||||
const endTime = payload.EndTime;
|
||||
const duration = this.parseDuration(payload.CallDuration ?? '00:00:00');
|
||||
const agentName = payload.AgentName ?? null;
|
||||
const recordingUrl = payload.AudioFile ?? null;
|
||||
const ucid = payload.monitorUCID ?? null;
|
||||
const disposition = payload.Disposition ?? null;
|
||||
const hangupBy = payload.HangupBy ?? null;
|
||||
|
||||
if (!callerPhone) {
|
||||
this.logger.warn('No caller phone in webhook — skipping');
|
||||
return { received: true, processed: false };
|
||||
}
|
||||
|
||||
// Determine call status for our platform
|
||||
const callStatus = status === 'Answered' ? 'COMPLETED' : 'MISSED';
|
||||
const direction = type === 'InBound' ? 'INBOUND' : 'OUTBOUND';
|
||||
|
||||
// Use API key auth for server-to-server writes
|
||||
const authHeader = this.apiKey ? `Bearer ${this.apiKey}` : '';
|
||||
if (!authHeader) {
|
||||
this.logger.warn('No PLATFORM_API_KEY configured — cannot write call records');
|
||||
return { received: true, processed: false };
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Create call record
|
||||
const callId = await this.createCall({
|
||||
callerPhone,
|
||||
direction,
|
||||
callStatus,
|
||||
agentName,
|
||||
startTime,
|
||||
endTime,
|
||||
duration,
|
||||
recordingUrl,
|
||||
disposition,
|
||||
ucid,
|
||||
}, authHeader);
|
||||
|
||||
this.logger.log(`Created call record: ${callId} (${callStatus})`);
|
||||
|
||||
// Step 2: Find matching lead by phone number
|
||||
const lead = await this.findLeadByPhone(callerPhone, authHeader);
|
||||
|
||||
if (lead) {
|
||||
// Step 3: Link call to lead
|
||||
await this.updateCall(callId, { leadId: lead.id }, authHeader);
|
||||
|
||||
// Step 4: Create lead activity
|
||||
const summary = callStatus === 'MISSED'
|
||||
? `Missed inbound call from ${callerPhone} (${duration}s, ${hangupBy ?? 'unknown'})`
|
||||
: `Inbound call from ${callerPhone} — ${duration}s, ${disposition || 'no disposition'}`;
|
||||
|
||||
await this.createLeadActivity({
|
||||
leadId: lead.id,
|
||||
activityType: callStatus === 'MISSED' ? 'CALL_RECEIVED' : 'CALL_RECEIVED',
|
||||
summary,
|
||||
channel: 'PHONE',
|
||||
performedBy: agentName ?? 'System',
|
||||
durationSeconds: duration,
|
||||
outcome: callStatus === 'MISSED' ? 'NO_ANSWER' : 'SUCCESSFUL',
|
||||
}, authHeader);
|
||||
|
||||
// Step 5: Update lead contact timestamps
|
||||
await this.updateLead(lead.id, {
|
||||
lastContacted: startTime ? new Date(startTime).toISOString() : new Date().toISOString(),
|
||||
contactAttempts: (lead.contactAttempts ?? 0) + 1,
|
||||
}, authHeader);
|
||||
|
||||
this.logger.log(`Linked call to lead ${lead.id} (${lead.name}), activity created`);
|
||||
} else {
|
||||
this.logger.log(`No matching lead for ${callerPhone} — call record created without lead link`);
|
||||
}
|
||||
|
||||
return { received: true, processed: true, callId, leadId: lead?.id ?? null };
|
||||
} catch (err: any) {
|
||||
const responseData = err?.response?.data ? JSON.stringify(err.response.data) : '';
|
||||
this.logger.error(`Webhook processing failed: ${err.message} ${responseData}`);
|
||||
return { received: true, processed: false, error: String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
// Determine call status for our platform
|
||||
const callStatus = status === 'Answered' ? 'COMPLETED' : 'MISSED';
|
||||
const direction = type === 'InBound' ? 'INBOUND' : 'OUTBOUND';
|
||||
private async createCall(data: {
|
||||
callerPhone: string;
|
||||
direction: string;
|
||||
callStatus: string;
|
||||
agentName: string | null;
|
||||
startTime: string | null;
|
||||
endTime: string | null;
|
||||
duration: number;
|
||||
recordingUrl: string | null;
|
||||
disposition: string | null;
|
||||
ucid: string | null;
|
||||
}, authHeader: string): Promise<string> {
|
||||
const callData: Record<string, any> = {
|
||||
name: `${data.direction === 'INBOUND' ? 'Inbound' : 'Outbound'} — ${data.callerPhone}`,
|
||||
direction: data.direction,
|
||||
callStatus: data.callStatus,
|
||||
callerNumber: { primaryPhoneNumber: `+91${data.callerPhone}` },
|
||||
agentName: data.agentName,
|
||||
startedAt: istToUtc(data.startTime),
|
||||
endedAt: istToUtc(data.endTime),
|
||||
durationSec: data.duration,
|
||||
disposition: this.mapDisposition(data.disposition),
|
||||
};
|
||||
// Set callback tracking fields for missed calls so they appear in the worklist
|
||||
if (data.callStatus === 'MISSED') {
|
||||
callData.callbackstatus = 'PENDING_CALLBACK';
|
||||
callData.missedcallcount = 1;
|
||||
}
|
||||
if (data.recordingUrl) {
|
||||
callData.recording = { primaryLinkUrl: data.recordingUrl, primaryLinkLabel: 'Recording' };
|
||||
}
|
||||
|
||||
// Use API key auth for server-to-server writes
|
||||
const authHeader = this.apiKey ? `Bearer ${this.apiKey}` : '';
|
||||
if (!authHeader) {
|
||||
this.logger.warn(
|
||||
'No PLATFORM_API_KEY configured — cannot write call records',
|
||||
);
|
||||
return { received: true, processed: false };
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Create call record
|
||||
const callId = await this.createCall(
|
||||
{
|
||||
callerPhone,
|
||||
direction,
|
||||
callStatus,
|
||||
agentName,
|
||||
startTime,
|
||||
endTime,
|
||||
duration,
|
||||
recordingUrl,
|
||||
disposition,
|
||||
ucid,
|
||||
},
|
||||
authHeader,
|
||||
);
|
||||
|
||||
this.logger.log(`Created call record: ${callId} (${callStatus})`);
|
||||
|
||||
// Step 2: Find matching lead by phone number
|
||||
const lead = await this.findLeadByPhone(callerPhone, authHeader);
|
||||
|
||||
if (lead) {
|
||||
// Step 3: Link call to lead
|
||||
await this.updateCall(callId, { leadId: lead.id }, authHeader);
|
||||
|
||||
// Step 4: Create lead activity
|
||||
const summary =
|
||||
callStatus === 'MISSED'
|
||||
? `Missed inbound call from ${callerPhone} (${duration}s, ${hangupBy ?? 'unknown'})`
|
||||
: `Inbound call from ${callerPhone} — ${duration}s, ${disposition || 'no disposition'}`;
|
||||
|
||||
await this.createLeadActivity(
|
||||
{
|
||||
leadId: lead.id,
|
||||
activityType:
|
||||
callStatus === 'MISSED' ? 'CALL_RECEIVED' : 'CALL_RECEIVED',
|
||||
summary,
|
||||
channel: 'PHONE',
|
||||
performedBy: agentName ?? 'System',
|
||||
durationSeconds: duration,
|
||||
outcome: callStatus === 'MISSED' ? 'NO_ANSWER' : 'SUCCESSFUL',
|
||||
},
|
||||
authHeader,
|
||||
const result = await this.platform.queryWithAuth<any>(
|
||||
`mutation($data: CallCreateInput!) { createCall(data: $data) { id } }`,
|
||||
{ data: callData },
|
||||
authHeader,
|
||||
);
|
||||
|
||||
// Step 5: Update lead contact timestamps
|
||||
await this.updateLead(
|
||||
lead.id,
|
||||
{
|
||||
lastContacted: startTime
|
||||
? new Date(startTime).toISOString()
|
||||
: new Date().toISOString(),
|
||||
contactAttempts: (lead.contactAttempts ?? 0) + 1,
|
||||
},
|
||||
authHeader,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Linked call to lead ${lead.id} (${lead.name}), activity created`,
|
||||
);
|
||||
} else {
|
||||
this.logger.log(
|
||||
`No matching lead for ${callerPhone} — call record created without lead link`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
received: true,
|
||||
processed: true,
|
||||
callId,
|
||||
leadId: lead?.id ?? null,
|
||||
};
|
||||
} catch (err: any) {
|
||||
const responseData = err?.response?.data
|
||||
? JSON.stringify(err.response.data)
|
||||
: '';
|
||||
this.logger.error(
|
||||
`Webhook processing failed: ${err.message} ${responseData}`,
|
||||
);
|
||||
return { received: true, processed: false, error: String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async createCall(
|
||||
data: {
|
||||
callerPhone: string;
|
||||
direction: string;
|
||||
callStatus: string;
|
||||
agentName: string | null;
|
||||
startTime: string | null;
|
||||
endTime: string | null;
|
||||
duration: number;
|
||||
recordingUrl: string | null;
|
||||
disposition: string | null;
|
||||
ucid: string | null;
|
||||
},
|
||||
authHeader: string,
|
||||
): Promise<string> {
|
||||
const callData: Record<string, any> = {
|
||||
name: `${data.direction === 'INBOUND' ? 'Inbound' : 'Outbound'} — ${data.callerPhone}`,
|
||||
direction: data.direction,
|
||||
callStatus: data.callStatus,
|
||||
callerNumber: { primaryPhoneNumber: `+91${data.callerPhone}` },
|
||||
agentName: data.agentName,
|
||||
startedAt: data.startTime ? new Date(data.startTime).toISOString() : null,
|
||||
endedAt: data.endTime ? new Date(data.endTime).toISOString() : null,
|
||||
durationSec: data.duration,
|
||||
disposition: this.mapDisposition(data.disposition),
|
||||
};
|
||||
// Set callback tracking fields for missed calls so they appear in the worklist
|
||||
if (data.callStatus === 'MISSED') {
|
||||
callData.callbackstatus = 'PENDING_CALLBACK';
|
||||
callData.missedcallcount = 1;
|
||||
}
|
||||
if (data.recordingUrl) {
|
||||
callData.recording = {
|
||||
primaryLinkUrl: data.recordingUrl,
|
||||
primaryLinkLabel: 'Recording',
|
||||
};
|
||||
return result.createCall.id;
|
||||
}
|
||||
|
||||
const result = await this.platform.queryWithAuth<any>(
|
||||
`mutation($data: CallCreateInput!) { createCall(data: $data) { id } }`,
|
||||
{ data: callData },
|
||||
authHeader,
|
||||
);
|
||||
return result.createCall.id;
|
||||
}
|
||||
|
||||
private async findLeadByPhone(
|
||||
phone: string,
|
||||
authHeader: string,
|
||||
): Promise<{ id: string; name: string; contactAttempts: number } | null> {
|
||||
const result = await this.platform.queryWithAuth<any>(
|
||||
`{ leads(first: 50) { edges { node { id name contactPhone { primaryPhoneNumber } contactAttempts lastContacted } } } }`,
|
||||
undefined,
|
||||
authHeader,
|
||||
);
|
||||
const leads = result.leads.edges.map((e: any) => e.node);
|
||||
const cleanPhone = phone.replace(/\D/g, '');
|
||||
|
||||
return (
|
||||
leads.find((l: any) => {
|
||||
const lp = (l.contactPhone?.primaryPhoneNumber ?? '').replace(
|
||||
/\D/g,
|
||||
'',
|
||||
private async findLeadByPhone(phone: string, authHeader: string): Promise<{ id: string; name: string; contactAttempts: number } | null> {
|
||||
const result = await this.platform.queryWithAuth<any>(
|
||||
`{ leads(first: 50) { edges { node { id name contactPhone { primaryPhoneNumber } contactAttempts lastContacted } } } }`,
|
||||
undefined,
|
||||
authHeader,
|
||||
);
|
||||
return lp.endsWith(cleanPhone) || cleanPhone.endsWith(lp);
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
const leads = result.leads.edges.map((e: any) => e.node);
|
||||
const cleanPhone = phone.replace(/\D/g, '');
|
||||
|
||||
private async updateCall(
|
||||
callId: string,
|
||||
data: Record<string, any>,
|
||||
authHeader: string,
|
||||
): Promise<void> {
|
||||
await this.platform.queryWithAuth<any>(
|
||||
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
||||
{ id: callId, data },
|
||||
authHeader,
|
||||
);
|
||||
}
|
||||
return leads.find((l: any) => {
|
||||
const lp = (l.contactPhone?.primaryPhoneNumber ?? '').replace(/\D/g, '');
|
||||
return lp.endsWith(cleanPhone) || cleanPhone.endsWith(lp);
|
||||
}) ?? null;
|
||||
}
|
||||
|
||||
private async createLeadActivity(
|
||||
data: {
|
||||
leadId: string;
|
||||
activityType: string;
|
||||
summary: string;
|
||||
channel: string;
|
||||
performedBy: string;
|
||||
durationSeconds: number;
|
||||
outcome: string;
|
||||
},
|
||||
authHeader: string,
|
||||
): Promise<void> {
|
||||
await this.platform.queryWithAuth<any>(
|
||||
`mutation($data: LeadActivityCreateInput!) { createLeadActivity(data: $data) { id } }`,
|
||||
{
|
||||
data: {
|
||||
name: data.summary.substring(0, 80),
|
||||
activityType: data.activityType,
|
||||
summary: data.summary,
|
||||
occurredAt: new Date().toISOString(),
|
||||
performedBy: data.performedBy,
|
||||
channel: data.channel,
|
||||
durationSec: data.durationSeconds,
|
||||
outcome: data.outcome,
|
||||
leadId: data.leadId,
|
||||
},
|
||||
},
|
||||
authHeader,
|
||||
);
|
||||
}
|
||||
private async updateCall(callId: string, data: Record<string, any>, authHeader: string): Promise<void> {
|
||||
await this.platform.queryWithAuth<any>(
|
||||
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
||||
{ id: callId, data },
|
||||
authHeader,
|
||||
);
|
||||
}
|
||||
|
||||
private async updateLead(
|
||||
leadId: string,
|
||||
data: Record<string, any>,
|
||||
authHeader: string,
|
||||
): Promise<void> {
|
||||
await this.platform.queryWithAuth<any>(
|
||||
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
||||
{ id: leadId, data },
|
||||
authHeader,
|
||||
);
|
||||
}
|
||||
private async createLeadActivity(data: {
|
||||
leadId: string;
|
||||
activityType: string;
|
||||
summary: string;
|
||||
channel: string;
|
||||
performedBy: string;
|
||||
durationSeconds: number;
|
||||
outcome: string;
|
||||
}, authHeader: string): Promise<void> {
|
||||
await this.platform.queryWithAuth<any>(
|
||||
`mutation($data: LeadActivityCreateInput!) { createLeadActivity(data: $data) { id } }`,
|
||||
{
|
||||
data: {
|
||||
name: data.summary.substring(0, 80),
|
||||
activityType: data.activityType,
|
||||
summary: data.summary,
|
||||
occurredAt: new Date().toISOString(),
|
||||
performedBy: data.performedBy,
|
||||
channel: data.channel,
|
||||
durationSec: data.durationSeconds,
|
||||
outcome: data.outcome,
|
||||
leadId: data.leadId,
|
||||
},
|
||||
},
|
||||
authHeader,
|
||||
);
|
||||
}
|
||||
|
||||
private parseDuration(timeStr: string): number {
|
||||
const parts = timeStr.split(':').map(Number);
|
||||
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
if (parts.length === 2) return parts[0] * 60 + parts[1];
|
||||
return parseInt(timeStr) || 0;
|
||||
}
|
||||
private async updateLead(leadId: string, data: Record<string, any>, authHeader: string): Promise<void> {
|
||||
await this.platform.queryWithAuth<any>(
|
||||
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
||||
{ id: leadId, data },
|
||||
authHeader,
|
||||
);
|
||||
}
|
||||
|
||||
private mapDisposition(disposition: string | null): string | null {
|
||||
if (!disposition) return null;
|
||||
const map: Record<string, string> = {
|
||||
'General Enquiry': 'INFO_PROVIDED',
|
||||
'Appointment Booked': 'APPOINTMENT_BOOKED',
|
||||
'Follow Up': 'FOLLOW_UP_SCHEDULED',
|
||||
'Not Interested': 'CALLBACK_REQUESTED',
|
||||
'Wrong Number': 'WRONG_NUMBER',
|
||||
};
|
||||
return map[disposition] ?? null;
|
||||
}
|
||||
private parseDuration(timeStr: string): number {
|
||||
const parts = timeStr.split(':').map(Number);
|
||||
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
if (parts.length === 2) return parts[0] * 60 + parts[1];
|
||||
return parseInt(timeStr) || 0;
|
||||
}
|
||||
|
||||
private mapDisposition(disposition: string | null): string | null {
|
||||
if (!disposition) return null;
|
||||
const map: Record<string, string> = {
|
||||
'General Enquiry': 'INFO_PROVIDED',
|
||||
'Appointment Booked': 'APPOINTMENT_BOOKED',
|
||||
'Follow Up': 'FOLLOW_UP_SCHEDULED',
|
||||
'Not Interested': 'CALLBACK_REQUESTED',
|
||||
'Wrong Number': 'WRONG_NUMBER',
|
||||
};
|
||||
return map[disposition] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,143 +3,163 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
||||
|
||||
// Ozonetel sends all timestamps in IST — convert to UTC for storage
|
||||
export function istToUtc(istDateStr: string | null): string | null {
|
||||
if (!istDateStr) return null;
|
||||
const d = new Date(istDateStr);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
d.setMinutes(d.getMinutes() - 330); // IST is UTC+5:30
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
// Normalize phone to +91XXXXXXXXXX format
|
||||
export function normalizePhone(raw: string): string {
|
||||
let digits = raw.replace(/[^0-9]/g, '');
|
||||
// Strip leading country code variations: 0091, 91, 0
|
||||
if (digits.startsWith('0091')) digits = digits.slice(4);
|
||||
else if (digits.startsWith('91') && digits.length > 10)
|
||||
digits = digits.slice(2);
|
||||
else if (digits.startsWith('0') && digits.length > 10)
|
||||
digits = digits.slice(1);
|
||||
return `+91${digits.slice(-10)}`;
|
||||
let digits = raw.replace(/[^0-9]/g, '');
|
||||
// Strip leading country code variations: 0091, 91, 0
|
||||
if (digits.startsWith('0091')) digits = digits.slice(4);
|
||||
else if (digits.startsWith('91') && digits.length > 10) digits = digits.slice(2);
|
||||
else if (digits.startsWith('0') && digits.length > 10) digits = digits.slice(1);
|
||||
return `+91${digits.slice(-10)}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MissedQueueService implements OnModuleInit {
|
||||
private readonly logger = new Logger(MissedQueueService.name);
|
||||
private readonly pollIntervalMs: number;
|
||||
private readonly processedUcids = new Set<string>();
|
||||
private assignmentMutex = false;
|
||||
private readonly logger = new Logger(MissedQueueService.name);
|
||||
private readonly pollIntervalMs: number;
|
||||
private readonly processedUcids = new Set<string>();
|
||||
private assignmentMutex = false;
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly platform: PlatformGraphqlService,
|
||||
private readonly ozonetel: OzonetelAgentService,
|
||||
) {
|
||||
this.pollIntervalMs = this.config.get<number>(
|
||||
'missedQueue.pollIntervalMs',
|
||||
30000,
|
||||
);
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
this.logger.log(
|
||||
`Starting missed call ingestion polling every ${this.pollIntervalMs}ms`,
|
||||
);
|
||||
setInterval(
|
||||
() =>
|
||||
this.ingest().catch((err) =>
|
||||
this.logger.error('Ingestion failed', err),
|
||||
),
|
||||
this.pollIntervalMs,
|
||||
);
|
||||
}
|
||||
|
||||
async ingest(): Promise<{ created: number; updated: number }> {
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
|
||||
// Ozonetel fromTime/toTime use HH:MM:SS format (time of day, filters within current day)
|
||||
const now = new Date();
|
||||
const fiveMinAgo = new Date(now.getTime() - 5 * 60 * 1000);
|
||||
const toHHMMSS = (d: Date) => d.toTimeString().slice(0, 8);
|
||||
|
||||
let abandonCalls: any[];
|
||||
try {
|
||||
abandonCalls = await this.ozonetel.getAbandonCalls({
|
||||
fromTime: toHHMMSS(fiveMinAgo),
|
||||
toTime: toHHMMSS(now),
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to fetch abandon calls: ${err}`);
|
||||
return { created: 0, updated: 0 };
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly platform: PlatformGraphqlService,
|
||||
private readonly ozonetel: OzonetelAgentService,
|
||||
) {
|
||||
this.pollIntervalMs = this.config.get<number>('missedQueue.pollIntervalMs', 30000);
|
||||
}
|
||||
|
||||
if (!abandonCalls?.length) return { created: 0, updated: 0 };
|
||||
onModuleInit() {
|
||||
this.logger.log(`Starting missed call ingestion polling every ${this.pollIntervalMs}ms`);
|
||||
setInterval(() => this.ingest().catch(err => this.logger.error('Ingestion failed', err)), this.pollIntervalMs);
|
||||
}
|
||||
|
||||
for (const call of abandonCalls) {
|
||||
const ucid = call.monitorUCID;
|
||||
if (!ucid || this.processedUcids.has(ucid)) continue;
|
||||
this.processedUcids.add(ucid);
|
||||
async ingest(): Promise<{ created: number; updated: number }> {
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
|
||||
const phone = normalizePhone(call.callerID || '');
|
||||
if (!phone || phone.length < 13) continue;
|
||||
// Ozonetel fromTime/toTime use HH:MM:SS format (time of day, filters within current day)
|
||||
const now = new Date();
|
||||
const fiveMinAgo = new Date(now.getTime() - 5 * 60 * 1000);
|
||||
const toHHMMSS = (d: Date) => d.toTimeString().slice(0, 8);
|
||||
|
||||
const did = call.did || '';
|
||||
const callTime = call.callTime || new Date().toISOString();
|
||||
let abandonCalls: any[];
|
||||
try {
|
||||
abandonCalls = await this.ozonetel.getAbandonCalls({ fromTime: toHHMMSS(fiveMinAgo), toTime: toHHMMSS(now) });
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to fetch abandon calls: ${err}`);
|
||||
return { created: 0, updated: 0 };
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await this.platform.query<any>(
|
||||
`{ calls(first: 1, filter: {
|
||||
if (!abandonCalls?.length) return { created: 0, updated: 0 };
|
||||
|
||||
for (const call of abandonCalls) {
|
||||
const ucid = call.monitorUCID;
|
||||
if (!ucid || this.processedUcids.has(ucid)) continue;
|
||||
this.processedUcids.add(ucid);
|
||||
|
||||
const phone = normalizePhone(call.callerID || '');
|
||||
if (!phone || phone.length < 13) continue;
|
||||
|
||||
const did = call.did || '';
|
||||
const callTime = istToUtc(call.callTime) ?? new Date().toISOString();
|
||||
|
||||
try {
|
||||
// Look up lead by phone number — strip +91 prefix for flexible matching
|
||||
const phoneDigits = phone.replace(/^\+91/, '');
|
||||
let leadId: string | null = null;
|
||||
let leadName: string | null = null;
|
||||
try {
|
||||
const leadResult = await this.platform.query<any>(
|
||||
`{ leads(first: 1, filter: {
|
||||
contactPhone: { primaryPhoneNumber: { like: "%${phoneDigits}" } }
|
||||
}) { edges { node { id contactName { firstName lastName } patientId } } } }`,
|
||||
);
|
||||
const matchedLead = leadResult?.leads?.edges?.[0]?.node;
|
||||
if (matchedLead) {
|
||||
leadId = matchedLead.id;
|
||||
const fn = matchedLead.contactName?.firstName ?? '';
|
||||
const ln = matchedLead.contactName?.lastName ?? '';
|
||||
leadName = `${fn} ${ln}`.trim() || null;
|
||||
this.logger.log(`Matched missed call ${phone} → lead ${leadId} (${leadName})`);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Lead lookup failed for ${phone}: ${err}`);
|
||||
}
|
||||
|
||||
const existing = await this.platform.query<any>(
|
||||
`{ calls(first: 1, filter: {
|
||||
callbackstatus: { eq: PENDING_CALLBACK },
|
||||
callerNumber: { primaryPhoneNumber: { eq: "${phone}" } }
|
||||
}) { edges { node { id missedcallcount } } } }`,
|
||||
);
|
||||
);
|
||||
|
||||
const existingNode = existing?.calls?.edges?.[0]?.node;
|
||||
const existingNode = existing?.calls?.edges?.[0]?.node;
|
||||
|
||||
if (existingNode) {
|
||||
const newCount = (existingNode.missedcallcount || 1) + 1;
|
||||
await this.platform.query<any>(
|
||||
`mutation { updateCall(id: "${existingNode.id}", data: {
|
||||
missedcallcount: ${newCount},
|
||||
startedAt: "${callTime}",
|
||||
callsourcenumber: "${did}"
|
||||
}) { id } }`,
|
||||
);
|
||||
updated++;
|
||||
this.logger.log(`Dedup missed call ${phone}: count now ${newCount}`);
|
||||
} else {
|
||||
await this.platform.query<any>(
|
||||
`mutation { createCall(data: {
|
||||
callStatus: MISSED,
|
||||
direction: INBOUND,
|
||||
callerNumber: { primaryPhoneNumber: "${phone}", primaryPhoneCallingCode: "+91" },
|
||||
callsourcenumber: "${did}",
|
||||
callbackstatus: PENDING_CALLBACK,
|
||||
missedcallcount: 1,
|
||||
startedAt: "${callTime}"
|
||||
}) { id } }`,
|
||||
);
|
||||
created++;
|
||||
this.logger.log(`Created missed call record for ${phone}`);
|
||||
if (existingNode) {
|
||||
const newCount = (existingNode.missedcallcount || 1) + 1;
|
||||
const updateParts = [
|
||||
`missedcallcount: ${newCount}`,
|
||||
`startedAt: "${callTime}"`,
|
||||
`callsourcenumber: "${did}"`,
|
||||
];
|
||||
if (leadId) updateParts.push(`leadId: "${leadId}"`);
|
||||
if (leadName) updateParts.push(`leadName: "${leadName}"`);
|
||||
await this.platform.query<any>(
|
||||
`mutation { updateCall(id: "${existingNode.id}", data: { ${updateParts.join(', ')} }) { id } }`,
|
||||
);
|
||||
updated++;
|
||||
this.logger.log(`Dedup missed call ${phone}: count now ${newCount}${leadName ? ` (${leadName})` : ''}`);
|
||||
} else {
|
||||
const dataParts = [
|
||||
`callStatus: MISSED`,
|
||||
`direction: INBOUND`,
|
||||
`callerNumber: { primaryPhoneNumber: "${phone}", primaryPhoneCallingCode: "+91" }`,
|
||||
`callsourcenumber: "${did}"`,
|
||||
`callbackstatus: PENDING_CALLBACK`,
|
||||
`missedcallcount: 1`,
|
||||
`startedAt: "${callTime}"`,
|
||||
];
|
||||
if (leadId) dataParts.push(`leadId: "${leadId}"`);
|
||||
if (leadName) dataParts.push(`leadName: "${leadName}"`);
|
||||
await this.platform.query<any>(
|
||||
`mutation { createCall(data: { ${dataParts.join(', ')} }) { id } }`,
|
||||
);
|
||||
created++;
|
||||
this.logger.log(`Created missed call record for ${phone}${leadName ? ` → ${leadName}` : ''}`);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to process abandon call ${ucid}: ${err}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to process abandon call ${ucid}: ${err}`);
|
||||
}
|
||||
|
||||
// Trim processedUcids to prevent unbounded growth
|
||||
if (this.processedUcids.size > 500) {
|
||||
const arr = Array.from(this.processedUcids);
|
||||
this.processedUcids.clear();
|
||||
arr.slice(-200).forEach(u => this.processedUcids.add(u));
|
||||
}
|
||||
|
||||
if (created || updated) this.logger.log(`Ingestion: ${created} created, ${updated} updated`);
|
||||
return { created, updated };
|
||||
}
|
||||
|
||||
// Trim processedUcids to prevent unbounded growth
|
||||
if (this.processedUcids.size > 500) {
|
||||
const arr = Array.from(this.processedUcids);
|
||||
this.processedUcids.clear();
|
||||
arr.slice(-200).forEach((u) => this.processedUcids.add(u));
|
||||
}
|
||||
async assignNext(agentName: string): Promise<any | null> {
|
||||
if (this.assignmentMutex) return null;
|
||||
this.assignmentMutex = true;
|
||||
|
||||
if (created || updated)
|
||||
this.logger.log(`Ingestion: ${created} created, ${updated} updated`);
|
||||
return { created, updated };
|
||||
}
|
||||
|
||||
async assignNext(agentName: string): Promise<any | null> {
|
||||
if (this.assignmentMutex) return null;
|
||||
this.assignmentMutex = true;
|
||||
|
||||
try {
|
||||
// Find oldest unassigned PENDING_CALLBACK call (empty agentName)
|
||||
let result = await this.platform.query<any>(
|
||||
`{ calls(first: 1, filter: {
|
||||
try {
|
||||
// Find oldest unassigned PENDING_CALLBACK call (empty agentName)
|
||||
let result = await this.platform.query<any>(
|
||||
`{ calls(first: 1, filter: {
|
||||
callbackstatus: { eq: PENDING_CALLBACK },
|
||||
agentName: { eq: "" }
|
||||
}, orderBy: [{ startedAt: AscNullsLast }]) {
|
||||
@@ -148,14 +168,14 @@ export class MissedQueueService implements OnModuleInit {
|
||||
startedAt callsourcenumber missedcallcount
|
||||
} }
|
||||
} }`,
|
||||
);
|
||||
);
|
||||
|
||||
let call = result?.calls?.edges?.[0]?.node;
|
||||
let call = result?.calls?.edges?.[0]?.node;
|
||||
|
||||
// Also check for null agentName
|
||||
if (!call) {
|
||||
result = await this.platform.query<any>(
|
||||
`{ calls(first: 1, filter: {
|
||||
// Also check for null agentName
|
||||
if (!call) {
|
||||
result = await this.platform.query<any>(
|
||||
`{ calls(first: 1, filter: {
|
||||
callbackstatus: { eq: PENDING_CALLBACK },
|
||||
agentName: { is: NULL }
|
||||
}, orderBy: [{ startedAt: AscNullsLast }]) {
|
||||
@@ -164,117 +184,80 @@ export class MissedQueueService implements OnModuleInit {
|
||||
startedAt callsourcenumber missedcallcount
|
||||
} }
|
||||
} }`,
|
||||
);
|
||||
call = result?.calls?.edges?.[0]?.node;
|
||||
}
|
||||
|
||||
if (!call) return null;
|
||||
|
||||
await this.platform.query<any>(
|
||||
`mutation { updateCall(id: "${call.id}", data: { agentName: "${agentName}" }) { id } }`,
|
||||
);
|
||||
this.logger.log(`Assigned missed call ${call.id} to ${agentName}`);
|
||||
return call;
|
||||
} catch (err) {
|
||||
this.logger.warn(`Assignment failed: ${err}`);
|
||||
return null;
|
||||
} finally {
|
||||
this.assignmentMutex = false;
|
||||
}
|
||||
}
|
||||
|
||||
async updateStatus(callId: string, status: string, authHeader: string): Promise<any> {
|
||||
const validStatuses = ['PENDING_CALLBACK', 'CALLBACK_ATTEMPTED', 'CALLBACK_COMPLETED', 'INVALID', 'WRONG_NUMBER'];
|
||||
if (!validStatuses.includes(status)) {
|
||||
throw new Error(`Invalid status: ${status}. Must be one of: ${validStatuses.join(', ')}`);
|
||||
}
|
||||
|
||||
const dataParts: string[] = [`callbackstatus: ${status}`];
|
||||
if (status === 'CALLBACK_ATTEMPTED') {
|
||||
dataParts.push(`callbackattemptedat: "${new Date().toISOString()}"`);
|
||||
}
|
||||
|
||||
return this.platform.queryWithAuth<any>(
|
||||
`mutation { updateCall(id: "${callId}", data: { ${dataParts.join(', ')} }) { id callbackstatus callbackattemptedat } }`,
|
||||
undefined,
|
||||
authHeader,
|
||||
);
|
||||
call = result?.calls?.edges?.[0]?.node;
|
||||
}
|
||||
|
||||
if (!call) return null;
|
||||
|
||||
await this.platform.query<any>(
|
||||
`mutation { updateCall(id: "${call.id}", data: { agentName: "${agentName}" }) { id } }`,
|
||||
);
|
||||
this.logger.log(`Assigned missed call ${call.id} to ${agentName}`);
|
||||
return call;
|
||||
} catch (err) {
|
||||
this.logger.warn(`Assignment failed: ${err}`);
|
||||
return null;
|
||||
} finally {
|
||||
this.assignmentMutex = false;
|
||||
}
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
callId: string,
|
||||
status: string,
|
||||
authHeader: string,
|
||||
): Promise<any> {
|
||||
const validStatuses = [
|
||||
'PENDING_CALLBACK',
|
||||
'CALLBACK_ATTEMPTED',
|
||||
'CALLBACK_COMPLETED',
|
||||
'INVALID',
|
||||
'WRONG_NUMBER',
|
||||
];
|
||||
if (!validStatuses.includes(status)) {
|
||||
throw new Error(
|
||||
`Invalid status: ${status}. Must be one of: ${validStatuses.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const dataParts: string[] = [`callbackstatus: ${status}`];
|
||||
if (status === 'CALLBACK_ATTEMPTED') {
|
||||
dataParts.push(`callbackattemptedat: "${new Date().toISOString()}"`);
|
||||
}
|
||||
|
||||
return this.platform.queryWithAuth<any>(
|
||||
`mutation { updateCall(id: "${callId}", data: { ${dataParts.join(', ')} }) { id callbackstatus callbackattemptedat } }`,
|
||||
undefined,
|
||||
authHeader,
|
||||
);
|
||||
}
|
||||
|
||||
async getMissedQueue(
|
||||
agentName: string,
|
||||
authHeader: string,
|
||||
): Promise<{
|
||||
pending: any[];
|
||||
attempted: any[];
|
||||
completed: any[];
|
||||
invalid: any[];
|
||||
}> {
|
||||
const fields = `id name createdAt direction callStatus agentName
|
||||
async getMissedQueue(agentName: string, authHeader: string): Promise<{
|
||||
pending: any[];
|
||||
attempted: any[];
|
||||
completed: any[];
|
||||
invalid: any[];
|
||||
}> {
|
||||
const fields = `id name createdAt direction callStatus agentName
|
||||
callerNumber { primaryPhoneNumber }
|
||||
startedAt endedAt durationSec disposition leadId
|
||||
callbackstatus callsourcenumber missedcallcount callbackattemptedat`;
|
||||
|
||||
const buildQuery = (status: string) => `{ calls(first: 50, filter: {
|
||||
const buildQuery = (status: string) => `{ calls(first: 50, filter: {
|
||||
agentName: { eq: "${agentName}" },
|
||||
callStatus: { eq: MISSED },
|
||||
callbackstatus: { eq: ${status} }
|
||||
}, orderBy: [{ startedAt: AscNullsLast }]) { edges { node { ${fields} } } } }`;
|
||||
|
||||
try {
|
||||
const [pending, attempted, completed, invalid, wrongNumber] =
|
||||
await Promise.all([
|
||||
this.platform.queryWithAuth<any>(
|
||||
buildQuery('PENDING_CALLBACK'),
|
||||
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,
|
||||
),
|
||||
]);
|
||||
try {
|
||||
const [pending, attempted, completed, invalid, wrongNumber] = await Promise.all([
|
||||
this.platform.queryWithAuth<any>(buildQuery('PENDING_CALLBACK'), 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) =>
|
||||
r?.calls?.edges?.map((e: any) => e.node) ?? [];
|
||||
const extract = (r: any) => r?.calls?.edges?.map((e: any) => e.node) ?? [];
|
||||
|
||||
return {
|
||||
pending: extract(pending),
|
||||
attempted: extract(attempted),
|
||||
completed: [...extract(completed), ...extract(wrongNumber)],
|
||||
invalid: extract(invalid),
|
||||
};
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to fetch missed queue: ${err}`);
|
||||
return { pending: [], attempted: [], completed: [], invalid: [] };
|
||||
return {
|
||||
pending: extract(pending),
|
||||
attempted: extract(attempted),
|
||||
completed: [...extract(completed), ...extract(wrongNumber)],
|
||||
invalid: extract(invalid),
|
||||
};
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to fetch missed queue: ${err}`);
|
||||
return { pending: [], attempted: [], completed: [], invalid: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,72 +1,69 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Patch,
|
||||
Headers,
|
||||
Param,
|
||||
Body,
|
||||
HttpException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Controller, Get, Patch, Headers, Param, Body, HttpException, Logger } from '@nestjs/common';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { WorklistService } from './worklist.service';
|
||||
import { MissedQueueService } from './missed-queue.service';
|
||||
import { SessionService } from '../auth/session.service';
|
||||
|
||||
@Controller('api/worklist')
|
||||
export class WorklistController {
|
||||
private readonly logger = new Logger(WorklistController.name);
|
||||
private readonly logger = new Logger(WorklistController.name);
|
||||
|
||||
constructor(
|
||||
private readonly worklist: WorklistService,
|
||||
private readonly missedQueue: MissedQueueService,
|
||||
private readonly platform: PlatformGraphqlService,
|
||||
) {}
|
||||
constructor(
|
||||
private readonly worklist: WorklistService,
|
||||
private readonly missedQueue: MissedQueueService,
|
||||
private readonly platform: PlatformGraphqlService,
|
||||
private readonly session: SessionService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async getWorklist(@Headers('authorization') authHeader: string) {
|
||||
if (!authHeader) {
|
||||
throw new HttpException('Authorization required', 401);
|
||||
@Get()
|
||||
async getWorklist(@Headers('authorization') authHeader: string) {
|
||||
if (!authHeader) {
|
||||
throw new HttpException('Authorization required', 401);
|
||||
}
|
||||
|
||||
const agentName = await this.resolveAgentName(authHeader);
|
||||
this.logger.log(`Fetching worklist for agent: ${agentName}`);
|
||||
|
||||
return this.worklist.getWorklist(agentName, authHeader);
|
||||
}
|
||||
|
||||
const agentName = await this.resolveAgentName(authHeader);
|
||||
this.logger.log(`Fetching worklist for agent: ${agentName}`);
|
||||
|
||||
return this.worklist.getWorklist(agentName, authHeader);
|
||||
}
|
||||
|
||||
@Get('missed-queue')
|
||||
async getMissedQueue(@Headers('authorization') authHeader: string) {
|
||||
if (!authHeader)
|
||||
throw new HttpException('Authorization header required', 401);
|
||||
const agentName = await this.resolveAgentName(authHeader);
|
||||
return this.missedQueue.getMissedQueue(agentName, authHeader);
|
||||
}
|
||||
|
||||
@Patch('missed-queue/:id/status')
|
||||
async updateMissedCallStatus(
|
||||
@Param('id') id: string,
|
||||
@Headers('authorization') authHeader: string,
|
||||
@Body() body: { status: string },
|
||||
) {
|
||||
if (!authHeader)
|
||||
throw new HttpException('Authorization header required', 401);
|
||||
if (!body.status) throw new HttpException('status is required', 400);
|
||||
return this.missedQueue.updateStatus(id, body.status, authHeader);
|
||||
}
|
||||
|
||||
private async resolveAgentName(authHeader: string): Promise<string> {
|
||||
try {
|
||||
const data = await this.platform.queryWithAuth<any>(
|
||||
`{ currentUser { workspaceMember { name { firstName lastName } } } }`,
|
||||
undefined,
|
||||
authHeader,
|
||||
);
|
||||
const name = data.currentUser?.workspaceMember?.name;
|
||||
const full = `${name?.firstName ?? ''} ${name?.lastName ?? ''}`.trim();
|
||||
if (full) return full;
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to resolve agent name: ${err}`);
|
||||
@Get('missed-queue')
|
||||
async getMissedQueue(@Headers('authorization') authHeader: string) {
|
||||
if (!authHeader) throw new HttpException('Authorization header required', 401);
|
||||
const agentName = await this.resolveAgentName(authHeader);
|
||||
return this.missedQueue.getMissedQueue(agentName, authHeader);
|
||||
}
|
||||
|
||||
@Patch('missed-queue/:id/status')
|
||||
async updateMissedCallStatus(
|
||||
@Param('id') id: string,
|
||||
@Headers('authorization') authHeader: string,
|
||||
@Body() body: { status: string },
|
||||
) {
|
||||
if (!authHeader) throw new HttpException('Authorization header required', 401);
|
||||
if (!body.status) throw new HttpException('status is required', 400);
|
||||
return this.missedQueue.updateStatus(id, body.status, authHeader);
|
||||
}
|
||||
|
||||
private async resolveAgentName(authHeader: string): Promise<string> {
|
||||
// Check cached name from login (avoids currentUser query that CC agents can't access)
|
||||
const token = authHeader.replace(/^Bearer\s+/i, '');
|
||||
const cached = await this.session.getCache(`agent:name:${token.slice(-16)}`);
|
||||
if (cached) return cached;
|
||||
|
||||
// Fallback: try querying platform (works for admin/supervisor tokens)
|
||||
try {
|
||||
const data = await this.platform.queryWithAuth<any>(
|
||||
`{ currentUser { workspaceMember { name { firstName lastName } } } }`,
|
||||
undefined,
|
||||
authHeader,
|
||||
);
|
||||
const name = data.currentUser?.workspaceMember?.name;
|
||||
const full = `${name?.firstName ?? ''} ${name?.lastName ?? ''}`.trim();
|
||||
if (full) return full;
|
||||
} catch (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 { PlatformModule } from '../platform/platform.module';
|
||||
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { RulesEngineModule } from '../rules-engine/rules-engine.module';
|
||||
import { WorklistController } from './worklist.controller';
|
||||
import { WorklistService } from './worklist.service';
|
||||
import { MissedQueueService } from './missed-queue.service';
|
||||
@@ -8,13 +10,9 @@ import { MissedCallWebhookController } from './missed-call-webhook.controller';
|
||||
import { KookooCallbackController } from './kookoo-callback.controller';
|
||||
|
||||
@Module({
|
||||
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule)],
|
||||
controllers: [
|
||||
WorklistController,
|
||||
MissedCallWebhookController,
|
||||
KookooCallbackController,
|
||||
],
|
||||
providers: [WorklistService, MissedQueueService],
|
||||
exports: [MissedQueueService],
|
||||
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule), forwardRef(() => AuthModule), RulesEngineModule],
|
||||
controllers: [WorklistController, MissedCallWebhookController, KookooCallbackController],
|
||||
providers: [WorklistService, MissedQueueService],
|
||||
exports: [MissedQueueService],
|
||||
})
|
||||
export class WorklistModule {}
|
||||
|
||||
@@ -1,45 +1,57 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { WorklistConsumer } from '../rules-engine/consumers/worklist.consumer';
|
||||
|
||||
export type WorklistResponse = {
|
||||
missedCalls: any[];
|
||||
followUps: any[];
|
||||
marketingLeads: any[];
|
||||
totalPending: number;
|
||||
missedCalls: any[];
|
||||
followUps: any[];
|
||||
marketingLeads: any[];
|
||||
totalPending: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
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(
|
||||
agentName: string,
|
||||
authHeader: string,
|
||||
): Promise<WorklistResponse> {
|
||||
const [missedCalls, followUps, marketingLeads] = await Promise.all([
|
||||
this.getMissedCalls(agentName, authHeader),
|
||||
this.getPendingFollowUps(agentName, authHeader),
|
||||
this.getAssignedLeads(agentName, authHeader),
|
||||
]);
|
||||
async getWorklist(agentName: string, authHeader: string): Promise<WorklistResponse> {
|
||||
const [rawMissedCalls, rawFollowUps, rawMarketingLeads] = await Promise.all([
|
||||
this.getMissedCalls(agentName, authHeader),
|
||||
this.getPendingFollowUps(agentName, authHeader),
|
||||
this.getAssignedLeads(agentName, authHeader),
|
||||
]);
|
||||
|
||||
return {
|
||||
missedCalls,
|
||||
followUps,
|
||||
marketingLeads,
|
||||
totalPending:
|
||||
missedCalls.length + followUps.length + marketingLeads.length,
|
||||
};
|
||||
}
|
||||
// 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' })),
|
||||
];
|
||||
|
||||
private async getAssignedLeads(
|
||||
agentName: string,
|
||||
authHeader: string,
|
||||
): Promise<any[]> {
|
||||
try {
|
||||
const data = await this.platform.queryWithAuth<any>(
|
||||
`{ leads(first: 20, filter: { assignedAgent: { eq: "${agentName}" } }, orderBy: [{ createdAt: AscNullsLast }]) { edges { node {
|
||||
// Score and rank via rules engine
|
||||
const scored = await this.worklistConsumer.scoreAndRank(combined);
|
||||
|
||||
// Split back into the 3 categories
|
||||
const missedCalls = scored.filter((item: any) => item.type === 'missed');
|
||||
const followUps = scored.filter((item: any) => item.type === 'follow-up');
|
||||
const marketingLeads = scored.filter((item: any) => item.type === 'lead');
|
||||
|
||||
return {
|
||||
missedCalls,
|
||||
followUps,
|
||||
marketingLeads,
|
||||
totalPending: missedCalls.length + followUps.length + marketingLeads.length,
|
||||
};
|
||||
}
|
||||
|
||||
private async getAssignedLeads(agentName: string, authHeader: string): Promise<any[]> {
|
||||
try {
|
||||
const data = await this.platform.queryWithAuth<any>(
|
||||
`{ leads(first: 20, filter: { assignedAgent: { eq: "${agentName}" } }, orderBy: [{ createdAt: AscNullsLast }]) { edges { node {
|
||||
id createdAt
|
||||
contactName { firstName lastName }
|
||||
contactPhone { primaryPhoneNumber }
|
||||
@@ -49,49 +61,43 @@ export class WorklistService {
|
||||
contactAttempts spamScore isSpam
|
||||
aiSummary aiSuggestedAction
|
||||
} } } }`,
|
||||
undefined,
|
||||
authHeader,
|
||||
);
|
||||
return data.leads.edges.map((e: any) => e.node);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to fetch assigned leads: ${err}`);
|
||||
return [];
|
||||
undefined,
|
||||
authHeader,
|
||||
);
|
||||
return data.leads.edges.map((e: any) => e.node);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to fetch assigned leads: ${err}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async getPendingFollowUps(
|
||||
agentName: string,
|
||||
authHeader: string,
|
||||
): Promise<any[]> {
|
||||
try {
|
||||
const data = await this.platform.queryWithAuth<any>(
|
||||
`{ followUps(first: 20, filter: { assignedAgent: { eq: "${agentName}" } }) { edges { node {
|
||||
private async getPendingFollowUps(agentName: string, authHeader: string): Promise<any[]> {
|
||||
try {
|
||||
const data = await this.platform.queryWithAuth<any>(
|
||||
`{ followUps(first: 20, filter: { assignedAgent: { eq: "${agentName}" } }) { edges { node {
|
||||
id name createdAt
|
||||
typeCustom status scheduledAt completedAt
|
||||
priority assignedAgent
|
||||
patientId
|
||||
} } } }`,
|
||||
undefined,
|
||||
authHeader,
|
||||
);
|
||||
// Filter to PENDING/OVERDUE client-side since platform may not support in-filter on remapped fields
|
||||
return data.followUps.edges
|
||||
.map((e: any) => e.node)
|
||||
.filter((f: any) => f.status === 'PENDING' || f.status === 'OVERDUE');
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to fetch follow-ups: ${err}`);
|
||||
return [];
|
||||
undefined,
|
||||
authHeader,
|
||||
);
|
||||
// Filter to PENDING/OVERDUE client-side since platform may not support in-filter on remapped fields
|
||||
return data.followUps.edges
|
||||
.map((e: any) => e.node)
|
||||
.filter((f: any) => f.status === 'PENDING' || f.status === 'OVERDUE');
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to fetch follow-ups: ${err}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async getMissedCalls(
|
||||
agentName: string,
|
||||
authHeader: string,
|
||||
): Promise<any[]> {
|
||||
try {
|
||||
// FIFO ordering (AscNullsLast) — oldest first. No agentName filter — missed calls are a shared queue.
|
||||
const data = await this.platform.queryWithAuth<any>(
|
||||
`{ calls(first: 20, filter: { callStatus: { eq: MISSED }, callbackstatus: { in: [PENDING_CALLBACK, CALLBACK_ATTEMPTED] } }, orderBy: [{ startedAt: AscNullsLast }]) { edges { node {
|
||||
private async getMissedCalls(agentName: string, authHeader: string): Promise<any[]> {
|
||||
try {
|
||||
// FIFO ordering (AscNullsLast) — oldest first. No agentName filter — missed calls are a shared queue.
|
||||
const data = await this.platform.queryWithAuth<any>(
|
||||
`{ calls(first: 20, filter: { callStatus: { eq: MISSED }, callbackstatus: { in: [PENDING_CALLBACK, CALLBACK_ATTEMPTED] } }, orderBy: [{ startedAt: AscNullsLast }]) { edges { node {
|
||||
id name createdAt
|
||||
direction callStatus agentName
|
||||
callerNumber { primaryPhoneNumber }
|
||||
@@ -99,13 +105,13 @@ export class WorklistService {
|
||||
disposition leadId
|
||||
callbackstatus callsourcenumber missedcallcount callbackattemptedat
|
||||
} } } }`,
|
||||
undefined,
|
||||
authHeader,
|
||||
);
|
||||
return data.calls.edges.map((e: any) => e.node);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to fetch missed calls: ${err}`);
|
||||
return [];
|
||||
undefined,
|
||||
authHeader,
|
||||
);
|
||||
return data.calls.edges.map((e: any) => e.node);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to fetch missed calls: ${err}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user