diff --git a/data/theme-backups/theme-2026-04-02T09-33-40-460Z.json b/data/theme-backups/theme-2026-04-02T09-33-40-460Z.json new file mode 100644 index 0000000..7aaad8c --- /dev/null +++ b/data/theme-backups/theme-2026-04-02T09-33-40-460Z.json @@ -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?" } + ] + } +} diff --git a/data/theme-backups/theme-2026-04-02T09-34-04-404Z.json b/data/theme-backups/theme-2026-04-02T09-34-04-404Z.json new file mode 100644 index 0000000..24033fa --- /dev/null +++ b/data/theme-backups/theme-2026-04-02T09-34-04-404Z.json @@ -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?" + } + ] + } +} \ No newline at end of file diff --git a/data/theme-backups/theme-2026-04-02T09-41-45-744Z.json b/data/theme-backups/theme-2026-04-02T09-41-45-744Z.json new file mode 100644 index 0000000..797bce7 --- /dev/null +++ b/data/theme-backups/theme-2026-04-02T09-41-45-744Z.json @@ -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?" + } + ] + } +} \ No newline at end of file diff --git a/data/theme-backups/theme-2026-04-02T09-42-24-047Z.json b/data/theme-backups/theme-2026-04-02T09-42-24-047Z.json new file mode 100644 index 0000000..9b43cdb --- /dev/null +++ b/data/theme-backups/theme-2026-04-02T09-42-24-047Z.json @@ -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?" + } + ] + } +} \ No newline at end of file diff --git a/data/theme-backups/theme-2026-04-02T09-43-19-186Z.json b/data/theme-backups/theme-2026-04-02T09-43-19-186Z.json new file mode 100644 index 0000000..ded5789 --- /dev/null +++ b/data/theme-backups/theme-2026-04-02T09-43-19-186Z.json @@ -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?" + } + ] + } +} \ No newline at end of file diff --git a/data/theme-backups/theme-2026-04-02T09-53-00-903Z.json b/data/theme-backups/theme-2026-04-02T09-53-00-903Z.json new file mode 100644 index 0000000..4e522f9 --- /dev/null +++ b/data/theme-backups/theme-2026-04-02T09-53-00-903Z.json @@ -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?" + } + ] + } +} \ No newline at end of file diff --git a/data/theme-backups/theme-2026-04-02T10-00-48-735Z.json b/data/theme-backups/theme-2026-04-02T10-00-48-735Z.json new file mode 100644 index 0000000..05d1ffd --- /dev/null +++ b/data/theme-backups/theme-2026-04-02T10-00-48-735Z.json @@ -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?" + } + ] + } +} \ No newline at end of file diff --git a/data/theme-backups/theme-2026-04-02T10-19-29-559Z.json b/data/theme-backups/theme-2026-04-02T10-19-29-559Z.json new file mode 100644 index 0000000..da764b4 --- /dev/null +++ b/data/theme-backups/theme-2026-04-02T10-19-29-559Z.json @@ -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?" + } + ] + } +} \ No newline at end of file diff --git a/data/theme-backups/theme-2026-04-02T10-19-35-284Z.json b/data/theme-backups/theme-2026-04-02T10-19-35-284Z.json new file mode 100644 index 0000000..68e414b --- /dev/null +++ b/data/theme-backups/theme-2026-04-02T10-19-35-284Z.json @@ -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" +} \ No newline at end of file diff --git a/data/theme.json b/data/theme.json new file mode 100644 index 0000000..cc44b03 --- /dev/null +++ b/data/theme.json @@ -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" +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f8649f4..10e6e87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,19 +12,24 @@ "@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", "@nestjs/platform-express": "^11.0.1", "@nestjs/platform-socket.io": "^11.1.17", - "@nestjs/swagger": "^11.2.6", "@nestjs/websockets": "^11.1.17", "ai": "^6.0.116", "axios": "^1.13.6", "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", @@ -40,9 +45,7 @@ "eslint-config-prettier": "^10.0.1", "eslint-plugin-prettier": "^5.2.2", "globals": "^16.0.0", - "husky": "^9.1.7", "jest": "^30.0.0", - "lint-staged": "^16.4.0", "prettier": "^3.4.2", "source-map-support": "^0.5.21", "supertest": "^7.0.0", @@ -803,6 +806,12 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/@bufbuild/protobuf": { + "version": "1.10.1", + "resolved": "http://localhost:4873/@bufbuild/protobuf/-/protobuf-1.10.1.tgz", + "integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "http://localhost:4873/@colors/colors/-/colors-1.5.0.tgz", @@ -838,6 +847,13 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@datastructures-js/deque": { + "version": "1.0.8", + "resolved": "http://localhost:4873/@datastructures-js/deque/-/deque-1.0.8.tgz", + "integrity": "sha512-PSBhJ2/SmeRPRHuBv7i/fHWIdSC3JTyq56qb+Rq0wjOagi0/fdV5/B/3Md5zFZus/W6OkSPMaxMKKMNMrSmubg==", + "license": "MIT", + "peer": true + }, "node_modules/@deepgram/sdk": { "version": "5.0.0", "resolved": "http://localhost:4873/@deepgram/sdk/-/sdk-5.0.0.tgz", @@ -866,7 +882,6 @@ "version": "1.9.0", "resolved": "http://localhost:4873/@emnapi/runtime/-/runtime-1.9.0.tgz", "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1483,6 +1498,155 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@ffmpeg-installer/darwin-arm64": { + "version": "4.1.5", + "resolved": "http://localhost:4873/@ffmpeg-installer/darwin-arm64/-/darwin-arm64-4.1.5.tgz", + "integrity": "sha512-hYqTiP63mXz7wSQfuqfFwfLOfwwFChUedeCVKkBtl/cliaTM7/ePI9bVzfZ2c+dWu3TqCwLDRWNSJ5pqZl8otA==", + "cpu": [ + "arm64" + ], + "hasInstallScript": true, + "license": "https://git.ffmpeg.org/gitweb/ffmpeg.git/blob_plain/HEAD:/LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@ffmpeg-installer/darwin-x64": { + "version": "4.1.0", + "resolved": "http://localhost:4873/@ffmpeg-installer/darwin-x64/-/darwin-x64-4.1.0.tgz", + "integrity": "sha512-Z4EyG3cIFjdhlY8wI9aLUXuH8nVt7E9SlMVZtWvSPnm2sm37/yC2CwjUzyCQbJbySnef1tQwGG2Sx+uWhd9IAw==", + "cpu": [ + "x64" + ], + "hasInstallScript": true, + "license": "LGPL-2.1", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@ffmpeg-installer/ffmpeg": { + "version": "1.1.0", + "resolved": "http://localhost:4873/@ffmpeg-installer/ffmpeg/-/ffmpeg-1.1.0.tgz", + "integrity": "sha512-Uq4rmwkdGxIa9A6Bd/VqqYbT7zqh1GrT5/rFwCwKM70b42W5gIjWeVETq6SdcL0zXqDtY081Ws/iJWhr1+xvQg==", + "license": "LGPL-2.1", + "optionalDependencies": { + "@ffmpeg-installer/darwin-arm64": "4.1.5", + "@ffmpeg-installer/darwin-x64": "4.1.0", + "@ffmpeg-installer/linux-arm": "4.1.3", + "@ffmpeg-installer/linux-arm64": "4.1.4", + "@ffmpeg-installer/linux-ia32": "4.1.0", + "@ffmpeg-installer/linux-x64": "4.1.0", + "@ffmpeg-installer/win32-ia32": "4.1.0", + "@ffmpeg-installer/win32-x64": "4.1.0" + } + }, + "node_modules/@ffmpeg-installer/linux-arm": { + "version": "4.1.3", + "resolved": "http://localhost:4873/@ffmpeg-installer/linux-arm/-/linux-arm-4.1.3.tgz", + "integrity": "sha512-NDf5V6l8AfzZ8WzUGZ5mV8O/xMzRag2ETR6+TlGIsMHp81agx51cqpPItXPib/nAZYmo55Bl2L6/WOMI3A5YRg==", + "cpu": [ + "arm" + ], + "hasInstallScript": true, + "license": "GPLv3", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ffmpeg-installer/linux-arm64": { + "version": "4.1.4", + "resolved": "http://localhost:4873/@ffmpeg-installer/linux-arm64/-/linux-arm64-4.1.4.tgz", + "integrity": "sha512-dljEqAOD0oIM6O6DxBW9US/FkvqvQwgJ2lGHOwHDDwu/pX8+V0YsDL1xqHbj1DMX/+nP9rxw7G7gcUvGspSoKg==", + "cpu": [ + "arm64" + ], + "hasInstallScript": true, + "license": "GPLv3", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ffmpeg-installer/linux-ia32": { + "version": "4.1.0", + "resolved": "http://localhost:4873/@ffmpeg-installer/linux-ia32/-/linux-ia32-4.1.0.tgz", + "integrity": "sha512-0LWyFQnPf+Ij9GQGD034hS6A90URNu9HCtQ5cTqo5MxOEc7Rd8gLXrJvn++UmxhU0J5RyRE9KRYstdCVUjkNOQ==", + "cpu": [ + "ia32" + ], + "hasInstallScript": true, + "license": "GPLv3", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ffmpeg-installer/linux-x64": { + "version": "4.1.0", + "resolved": "http://localhost:4873/@ffmpeg-installer/linux-x64/-/linux-x64-4.1.0.tgz", + "integrity": "sha512-Y5BWhGLU/WpQjOArNIgXD3z5mxxdV8c41C+U15nsE5yF8tVcdCGet5zPs5Zy3Ta6bU7haGpIzryutqCGQA/W8A==", + "cpu": [ + "x64" + ], + "hasInstallScript": true, + "license": "GPLv3", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ffmpeg-installer/win32-ia32": { + "version": "4.1.0", + "resolved": "http://localhost:4873/@ffmpeg-installer/win32-ia32/-/win32-ia32-4.1.0.tgz", + "integrity": "sha512-FV2D7RlaZv/lrtdhaQ4oETwoFUsUjlUiasiZLDxhEUPdNDWcH1OU9K1xTvqz+OXLdsmYelUDuBS/zkMOTtlUAw==", + "cpu": [ + "ia32" + ], + "license": "GPLv3", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@ffmpeg-installer/win32-x64": { + "version": "4.1.0", + "resolved": "http://localhost:4873/@ffmpeg-installer/win32-x64/-/win32-x64-4.1.0.tgz", + "integrity": "sha512-Drt5u2vzDnIONf4ZEkKtFlbvwj6rI3kxw1Ck9fpudmtgaZIHD4ucsWB2lCZBXRxJgXR+2IMSti+4rtM4C4rXgg==", + "cpu": [ + "x64" + ], + "license": "GPLv3", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@google/genai": { + "version": "1.46.0", + "resolved": "http://localhost:4873/@google/genai/-/genai-1.46.0.tgz", + "integrity": "sha512-ewPMN5JkKfgU5/kdco9ZhXBHDPhVqZpMQqIFQhwsHLf8kyZfx1cNpw1pHo1eV6PGEW7EhIBFi3aYZraFndAXqg==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "http://localhost:4873/@humanfs/core/-/core-0.19.1.tgz", @@ -1535,6 +1699,471 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "http://localhost:4873/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "http://localhost:4873/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "http://localhost:4873/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "http://localhost:4873/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "http://localhost:4873/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "http://localhost:4873/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "http://localhost:4873/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "http://localhost:4873/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "http://localhost:4873/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "http://localhost:4873/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "http://localhost:4873/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "http://localhost:4873/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "http://localhost:4873/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "http://localhost:4873/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "http://localhost:4873/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "http://localhost:4873/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "http://localhost:4873/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "http://localhost:4873/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "http://localhost:4873/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "http://localhost:4873/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "http://localhost:4873/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "http://localhost:4873/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "http://localhost:4873/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "http://localhost:4873/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "http://localhost:4873/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@inquirer/ansi": { "version": "1.0.2", "resolved": "http://localhost:4873/@inquirer/ansi/-/ansi-1.0.2.tgz", @@ -1994,6 +2623,18 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "http://localhost:4873/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "http://localhost:4873/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -2584,6 +3225,386 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@livekit/agents": { + "version": "1.2.1", + "resolved": "http://localhost:4873/@livekit/agents/-/agents-1.2.1.tgz", + "integrity": "sha512-2IQsJ6I9FqENL9E3sE4ttSukdAyUT6LMc+BamLkXM9/3paI2VEwR0Em+4e/uYptBc9X6nk6FWed6Axa8ZKa+gg==", + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "^1.10.0", + "@ffmpeg-installer/ffmpeg": "^1.1.0", + "@livekit/mutex": "^1.1.1", + "@livekit/protocol": "^1.45.1", + "@livekit/throws-transformer": "0.0.0-20260320165515", + "@livekit/typed-emitter": "^3.0.0", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api-logs": "^0.54.0", + "@opentelemetry/core": "^2.2.0", + "@opentelemetry/exporter-logs-otlp-proto": "^0.54.0", + "@opentelemetry/exporter-trace-otlp-proto": "^0.54.0", + "@opentelemetry/instrumentation-pino": "^0.43.0", + "@opentelemetry/otlp-exporter-base": "^0.208.0", + "@opentelemetry/resources": "^1.28.0", + "@opentelemetry/sdk-logs": "^0.54.0", + "@opentelemetry/sdk-trace-base": "^1.28.0", + "@opentelemetry/sdk-trace-node": "^1.28.0", + "@opentelemetry/semantic-conventions": "^1.28.0", + "@types/pidusage": "^2.0.5", + "commander": "^12.0.0", + "fluent-ffmpeg": "^2.1.3", + "form-data": "^4.0.5", + "heap-js": "^2.6.0", + "json-schema": "^0.4.0", + "livekit-server-sdk": "^2.14.1", + "ofetch": "^1.5.1", + "openai": "^6.8.1", + "pidusage": "^4.0.1", + "pino": "^8.19.0", + "pino-pretty": "^11.0.0", + "sharp": "0.34.5", + "uuid": "^11.1.0", + "ws": "^8.18.0", + "zod-to-json-schema": "^3.24.6" + }, + "peerDependencies": { + "@livekit/rtc-node": "^0.13.24", + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@livekit/agents-plugin-google": { + "version": "1.2.1", + "resolved": "http://localhost:4873/@livekit/agents-plugin-google/-/agents-plugin-google-1.2.1.tgz", + "integrity": "sha512-JBK3qSKmwopZs7WxNZA2RXYXphPCo3oPOtjDEEN9aYCU9aeM41gYusfFMsdoOs02xJcLlqMhHUx3bGxZACtfCA==", + "license": "Apache-2.0", + "dependencies": { + "@google/genai": "^1.44.0", + "@livekit/mutex": "^1.1.1", + "@types/json-schema": "^7.0.15", + "json-schema": "^0.4.0" + }, + "peerDependencies": { + "@livekit/agents": "1.2.1", + "@livekit/rtc-node": "^0.13.24" + } + }, + "node_modules/@livekit/agents-plugin-silero": { + "version": "1.2.1", + "resolved": "http://localhost:4873/@livekit/agents-plugin-silero/-/agents-plugin-silero-1.2.1.tgz", + "integrity": "sha512-2FXT5CkVWOhBfIZUP04S2h6WBsinga6Xd6PKVJxbmaYNZmJK/8Z22qfNDaHbxZxYxDDB2E/uXhOHOlUgj+AP3A==", + "license": "Apache-2.0", + "dependencies": { + "onnxruntime-node": "1.21.0", + "ws": "^8.16.0" + }, + "peerDependencies": { + "@livekit/agents": "1.2.1", + "@livekit/rtc-node": "^0.13.24" + } + }, + "node_modules/@livekit/agents/node_modules/commander": { + "version": "12.1.0", + "resolved": "http://localhost:4873/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@livekit/mutex": { + "version": "1.1.1", + "resolved": "http://localhost:4873/@livekit/mutex/-/mutex-1.1.1.tgz", + "integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==", + "license": "Apache-2.0" + }, + "node_modules/@livekit/protocol": { + "version": "1.45.1", + "resolved": "http://localhost:4873/@livekit/protocol/-/protocol-1.45.1.tgz", + "integrity": "sha512-sr6p0TwKofHO5KW6kUzjq4hH2de4Al5scQo824xFnyI1XYo0qQn6fTG+bdr+Uj4EedjYAOqjezwUju5OErVIRA==", + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "^1.10.0" + } + }, + "node_modules/@livekit/rtc-node": { + "version": "0.13.24", + "resolved": "http://localhost:4873/@livekit/rtc-node/-/rtc-node-0.13.24.tgz", + "integrity": "sha512-06pF8YJlJk11R6J7kFXFpwV8etpbmCskoXFvwfwcDDixMqaP6qtS5srq3G23mDaRjx7ofz/PXg2GtiZbqNGT5A==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@bufbuild/protobuf": "^1.10.1", + "@datastructures-js/deque": "1.0.8", + "@livekit/mutex": "^1.0.0", + "@livekit/typed-emitter": "^3.0.0", + "pino": "^9.0.0", + "pino-pretty": "^13.0.0" + }, + "engines": { + "node": ">= 18" + }, + "optionalDependencies": { + "@livekit/rtc-node-darwin-arm64": "0.13.24", + "@livekit/rtc-node-darwin-x64": "0.13.24", + "@livekit/rtc-node-linux-arm64-gnu": "0.13.24", + "@livekit/rtc-node-linux-x64-gnu": "0.13.24", + "@livekit/rtc-node-win32-x64-msvc": "0.13.24" + } + }, + "node_modules/@livekit/rtc-node-darwin-arm64": { + "version": "0.13.24", + "resolved": "http://localhost:4873/@livekit/rtc-node-darwin-arm64/-/rtc-node-darwin-arm64-0.13.24.tgz", + "integrity": "sha512-gm5xOpGu6Rj/mNU2jEijcGhQGN2GdxV2dNYQm3NCKN7ow0BmMFZvXSCAWOWf+9oTutPXHnrc7EN1mt2v+lfqhA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@livekit/rtc-node-darwin-x64": { + "version": "0.13.24", + "resolved": "http://localhost:4873/@livekit/rtc-node-darwin-x64/-/rtc-node-darwin-x64-0.13.24.tgz", + "integrity": "sha512-jZSK5lHDp7+u0jby7PEWMzbxc0F0nLx6FT3FVjuMlT13ZY6QWKDUUCFbfDOtbdhiOZJYc5A4SwvubY6woEJXTg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@livekit/rtc-node-linux-arm64-gnu": { + "version": "0.13.24", + "resolved": "http://localhost:4873/@livekit/rtc-node-linux-arm64-gnu/-/rtc-node-linux-arm64-gnu-0.13.24.tgz", + "integrity": "sha512-I+IeZET2h+viZ48moEFF0EWDHa+kLii5yuEsw38ya4mHZaZtlfbzrYKGKdONqbI9M9ldvv8XXuD0wFPjuH5CZw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@livekit/rtc-node-linux-x64-gnu": { + "version": "0.13.24", + "resolved": "http://localhost:4873/@livekit/rtc-node-linux-x64-gnu/-/rtc-node-linux-x64-gnu-0.13.24.tgz", + "integrity": "sha512-vKOxzN/SsrtV8zIVwZCi31bZUhlb6RhJZ0NnY5MwKGSRFPi7Dwt8fmr0Vh0YmsY/p+4eZjKxvFmy7L3WVE54zw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@livekit/rtc-node-win32-x64-msvc": { + "version": "0.13.24", + "resolved": "http://localhost:4873/@livekit/rtc-node-win32-x64-msvc/-/rtc-node-win32-x64-msvc-0.13.24.tgz", + "integrity": "sha512-yTzqwndq2oKLUkXW2i/BkZMJC6kZOpRO/DKvkkKQvqc3Q+JuWz1m48GmyjIwTOKF28QjqEU3+IrnD65Uu+mFOg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@livekit/rtc-node/node_modules/fast-copy": { + "version": "4.0.2", + "resolved": "http://localhost:4873/fast-copy/-/fast-copy-4.0.2.tgz", + "integrity": "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==", + "license": "MIT", + "peer": true + }, + "node_modules/@livekit/rtc-node/node_modules/pino": { + "version": "9.14.0", + "resolved": "http://localhost:4873/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/@livekit/rtc-node/node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "http://localhost:4873/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "peer": true, + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/@livekit/rtc-node/node_modules/pino-pretty": { + "version": "13.1.3", + "resolved": "http://localhost:4873/pino-pretty/-/pino-pretty-13.1.3.tgz", + "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", + "license": "MIT", + "peer": true, + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^4.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^4.0.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^5.0.2" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/@livekit/rtc-node/node_modules/pino-pretty/node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "http://localhost:4873/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "peer": true, + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/@livekit/rtc-node/node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "http://localhost:4873/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT", + "peer": true + }, + "node_modules/@livekit/rtc-node/node_modules/process-warning": { + "version": "5.0.0", + "resolved": "http://localhost:4873/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/@livekit/rtc-node/node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "http://localhost:4873/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@livekit/rtc-node/node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "http://localhost:4873/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/@livekit/rtc-node/node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "http://localhost:4873/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@livekit/rtc-node/node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "http://localhost:4873/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "license": "MIT", + "peer": true, + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/@livekit/throws-transformer": { + "version": "0.0.0-20260320165515", + "resolved": "http://localhost:4873/@livekit/throws-transformer/-/throws-transformer-0.0.0-20260320165515.tgz", + "integrity": "sha512-3L4UKOov1VXuX6sHIBuonJTaPzsSkpqZT3htvamgUYR0pL/aJ+0piiWzTPoCx9WSfmmUUAQqjd42IPgHXQVdvQ==", + "license": "Apache-2.0", + "dependencies": { + "glob": "^13.0.0" + }, + "bin": { + "throws-check": "dist/cli.js" + }, + "peerDependencies": { + "typescript": ">=4.7.0" + } + }, + "node_modules/@livekit/typed-emitter": { + "version": "3.0.0", + "resolved": "http://localhost:4873/@livekit/typed-emitter/-/typed-emitter-3.0.0.tgz", + "integrity": "sha512-9bl0k4MgBPZu3Qu3R3xy12rmbW17e3bE9yf4YY85gJIQ3ezLEj/uzpKHWBsLaDoL5Mozz8QCgggwIBudYQWeQg==", + "license": "MIT" + }, "node_modules/@lukeed/csprng": { "version": "1.1.0", "resolved": "http://localhost:4873/@lukeed/csprng/-/csprng-1.1.0.tgz", @@ -2593,12 +3614,6 @@ "node": ">=8" } }, - "node_modules/@microsoft/tsdoc": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz", - "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", - "license": "MIT" - }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "http://localhost:4873/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -2915,26 +3930,6 @@ } } }, - "node_modules/@nestjs/mapped-types": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.0.tgz", - "integrity": "sha512-W+n+rM69XsFdwORF11UqJahn4J3xi4g/ZEOlJNL6KoW5ygWSmBB2p0S2BZ4FQeS/NDH72e6xIcu35SfJnE8bXw==", - "license": "MIT", - "peerDependencies": { - "@nestjs/common": "^10.0.0 || ^11.0.0", - "class-transformer": "^0.4.0 || ^0.5.0", - "class-validator": "^0.13.0 || ^0.14.0", - "reflect-metadata": "^0.1.12 || ^0.2.0" - }, - "peerDependenciesMeta": { - "class-transformer": { - "optional": true - }, - "class-validator": { - "optional": true - } - } - }, "node_modules/@nestjs/platform-express": { "version": "11.1.17", "resolved": "http://localhost:4873/@nestjs/platform-express/-/platform-express-11.1.17.tgz", @@ -3073,39 +4068,6 @@ "tslib": "^2.1.0" } }, - "node_modules/@nestjs/swagger": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.2.6.tgz", - "integrity": "sha512-oiXOxMQqDFyv1AKAqFzSo6JPvMEs4uA36Eyz/s2aloZLxUjcLfUMELSLSNQunr61xCPTpwEOShfmO7NIufKXdA==", - "license": "MIT", - "dependencies": { - "@microsoft/tsdoc": "0.16.0", - "@nestjs/mapped-types": "2.1.0", - "js-yaml": "4.1.1", - "lodash": "4.17.23", - "path-to-regexp": "8.3.0", - "swagger-ui-dist": "5.31.0" - }, - "peerDependencies": { - "@fastify/static": "^8.0.0 || ^9.0.0", - "@nestjs/common": "^11.0.1", - "@nestjs/core": "^11.0.1", - "class-transformer": "*", - "class-validator": "*", - "reflect-metadata": "^0.1.12 || ^0.2.0" - }, - "peerDependenciesMeta": { - "@fastify/static": { - "optional": true - }, - "class-transformer": { - "optional": true - }, - "class-validator": { - "optional": true - } - } - }, "node_modules/@nestjs/testing": { "version": "11.1.17", "resolved": "http://localhost:4873/@nestjs/testing/-/testing-11.1.17.tgz", @@ -3195,6 +4157,825 @@ "node": ">=8.0.0" } }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.54.2", + "resolved": "http://localhost:4873/@opentelemetry/api-logs/-/api-logs-0.54.2.tgz", + "integrity": "sha512-4MTVwwmLgUh5QrJnZpYo6YRO5IBLAggf2h8gWDblwRagDStY13aEvt7gGk3jewrMaPlHiF83fENhIx0HO97/cQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/context-async-hooks": { + "version": "1.30.1", + "resolved": "http://localhost:4873/@opentelemetry/context-async-hooks/-/context-async-hooks-1.30.1.tgz", + "integrity": "sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.6.0", + "resolved": "http://localhost:4873/@opentelemetry/core/-/core-2.6.0.tgz", + "integrity": "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto": { + "version": "0.54.2", + "resolved": "http://localhost:4873/@opentelemetry/exporter-logs-otlp-proto/-/exporter-logs-otlp-proto-0.54.2.tgz", + "integrity": "sha512-agrzFbSNmIy6dhkyg41ERlEDUDqkaUJj2n/tVRFp9Tl+6wyNVPsqmwU5RWJOXpyK+lYH/znv6A47VpTeJF0lrw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.54.2", + "@opentelemetry/core": "1.27.0", + "@opentelemetry/otlp-exporter-base": "0.54.2", + "@opentelemetry/otlp-transformer": "0.54.2", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/sdk-logs": "0.54.2", + "@opentelemetry/sdk-trace-base": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/core": { + "version": "1.27.0", + "resolved": "http://localhost:4873/@opentelemetry/core/-/core-1.27.0.tgz", + "integrity": "sha512-yQPKnK5e+76XuiqUH/gKyS8wv/7qITd5ln56QkBTf3uggr0VkXOXfcaAuG330UfdYu83wsyoBwqwxigpIG+Jkg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.54.2", + "resolved": "http://localhost:4873/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.54.2.tgz", + "integrity": "sha512-NrNyxu6R/bGAwanhz1HI0aJWKR6xUED4TjCH4iWMlAfyRukGbI9Kt/Akd2sYLwRKNhfS+sKetKGCUQPMDyYYMA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.27.0", + "@opentelemetry/otlp-transformer": "0.54.2" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/resources": { + "version": "1.27.0", + "resolved": "http://localhost:4873/@opentelemetry/resources/-/resources-1.27.0.tgz", + "integrity": "sha512-jOwt2VJ/lUD5BLc+PMNymDrUCpm5PKi1E9oSVYAvz01U/VdndGmrtV3DU1pG4AwlYhJRHbHfOUIlpBeXCPw6QQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.27.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/sdk-trace-base": { + "version": "1.27.0", + "resolved": "http://localhost:4873/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.27.0.tgz", + "integrity": "sha512-btz6XTQzwsyJjombpeqCX6LhiMQYpzt2pIYNPnw0IPO/3AhT6yjnf8Mnv3ZC2A4eRYOjqrg+bfaXg9XHDRJDWQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.27.0", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "resolved": "http://localhost:4873/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-proto": { + "version": "0.54.2", + "resolved": "http://localhost:4873/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.54.2.tgz", + "integrity": "sha512-XSmm1N2wAhoWDXP1q/N6kpLebWaxl6VIADv4WA5QWKHLRpF3gLz5NAWNJBR8ygsvv8jQcrwnXgwfnJ18H3v1fg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.27.0", + "@opentelemetry/otlp-exporter-base": "0.54.2", + "@opentelemetry/otlp-transformer": "0.54.2", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/sdk-trace-base": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/core": { + "version": "1.27.0", + "resolved": "http://localhost:4873/@opentelemetry/core/-/core-1.27.0.tgz", + "integrity": "sha512-yQPKnK5e+76XuiqUH/gKyS8wv/7qITd5ln56QkBTf3uggr0VkXOXfcaAuG330UfdYu83wsyoBwqwxigpIG+Jkg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.54.2", + "resolved": "http://localhost:4873/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.54.2.tgz", + "integrity": "sha512-NrNyxu6R/bGAwanhz1HI0aJWKR6xUED4TjCH4iWMlAfyRukGbI9Kt/Akd2sYLwRKNhfS+sKetKGCUQPMDyYYMA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.27.0", + "@opentelemetry/otlp-transformer": "0.54.2" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/resources": { + "version": "1.27.0", + "resolved": "http://localhost:4873/@opentelemetry/resources/-/resources-1.27.0.tgz", + "integrity": "sha512-jOwt2VJ/lUD5BLc+PMNymDrUCpm5PKi1E9oSVYAvz01U/VdndGmrtV3DU1pG4AwlYhJRHbHfOUIlpBeXCPw6QQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.27.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/sdk-trace-base": { + "version": "1.27.0", + "resolved": "http://localhost:4873/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.27.0.tgz", + "integrity": "sha512-btz6XTQzwsyJjombpeqCX6LhiMQYpzt2pIYNPnw0IPO/3AhT6yjnf8Mnv3ZC2A4eRYOjqrg+bfaXg9XHDRJDWQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.27.0", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "resolved": "http://localhost:4873/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.54.2", + "resolved": "http://localhost:4873/@opentelemetry/instrumentation/-/instrumentation-0.54.2.tgz", + "integrity": "sha512-go6zpOVoZVztT9r1aPd79Fr3OWiD4N24bCPJsIKkBses8oyFo12F/Ew3UBTdIu6hsW4HC4MVEJygG6TEyJI/lg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.54.2", + "@types/shimmer": "^1.2.0", + "import-in-the-middle": "^1.8.1", + "require-in-the-middle": "^7.1.1", + "semver": "^7.5.2", + "shimmer": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-pino": { + "version": "0.43.0", + "resolved": "http://localhost:4873/@opentelemetry/instrumentation-pino/-/instrumentation-pino-0.43.0.tgz", + "integrity": "sha512-jlOOgbODWRRNknWXY1VLgmqgG0SO4kLgU3XnejjO/3De4OisroAsMGk+1cRB5AQ6WZ8WLAMkMyTShaOe6j2Asw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "^0.54.0", + "@opentelemetry/core": "^1.25.0", + "@opentelemetry/instrumentation": "^0.54.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-pino/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "resolved": "http://localhost:4873/@opentelemetry/core/-/core-1.30.1.tgz", + "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation-pino/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "http://localhost:4873/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.208.0", + "resolved": "http://localhost:4873/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.208.0.tgz", + "integrity": "sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/otlp-transformer": "0.208.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/api-logs": { + "version": "0.208.0", + "resolved": "http://localhost:4873/@opentelemetry/api-logs/-/api-logs-0.208.0.tgz", + "integrity": "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/core": { + "version": "2.2.0", + "resolved": "http://localhost:4873/@opentelemetry/core/-/core-2.2.0.tgz", + "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/otlp-transformer": { + "version": "0.208.0", + "resolved": "http://localhost:4873/@opentelemetry/otlp-transformer/-/otlp-transformer-0.208.0.tgz", + "integrity": "sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.208.0", + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0", + "@opentelemetry/sdk-logs": "0.208.0", + "@opentelemetry/sdk-metrics": "2.2.0", + "@opentelemetry/sdk-trace-base": "2.2.0", + "protobufjs": "^7.3.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/resources": { + "version": "2.2.0", + "resolved": "http://localhost:4873/@opentelemetry/resources/-/resources-2.2.0.tgz", + "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/sdk-logs": { + "version": "0.208.0", + "resolved": "http://localhost:4873/@opentelemetry/sdk-logs/-/sdk-logs-0.208.0.tgz", + "integrity": "sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.208.0", + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/sdk-metrics": { + "version": "2.2.0", + "resolved": "http://localhost:4873/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz", + "integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.2.0", + "resolved": "http://localhost:4873/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", + "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer": { + "version": "0.54.2", + "resolved": "http://localhost:4873/@opentelemetry/otlp-transformer/-/otlp-transformer-0.54.2.tgz", + "integrity": "sha512-2tIjahJlMRRUz0A2SeE+qBkeBXBFkSjR0wqJ08kuOqaL8HNGan5iZf+A8cfrfmZzPUuMKCyY9I+okzFuFs6gKQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.54.2", + "@opentelemetry/core": "1.27.0", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/sdk-logs": "0.54.2", + "@opentelemetry/sdk-metrics": "1.27.0", + "@opentelemetry/sdk-trace-base": "1.27.0", + "protobufjs": "^7.3.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/core": { + "version": "1.27.0", + "resolved": "http://localhost:4873/@opentelemetry/core/-/core-1.27.0.tgz", + "integrity": "sha512-yQPKnK5e+76XuiqUH/gKyS8wv/7qITd5ln56QkBTf3uggr0VkXOXfcaAuG330UfdYu83wsyoBwqwxigpIG+Jkg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": { + "version": "1.27.0", + "resolved": "http://localhost:4873/@opentelemetry/resources/-/resources-1.27.0.tgz", + "integrity": "sha512-jOwt2VJ/lUD5BLc+PMNymDrUCpm5PKi1E9oSVYAvz01U/VdndGmrtV3DU1pG4AwlYhJRHbHfOUIlpBeXCPw6QQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.27.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/sdk-trace-base": { + "version": "1.27.0", + "resolved": "http://localhost:4873/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.27.0.tgz", + "integrity": "sha512-btz6XTQzwsyJjombpeqCX6LhiMQYpzt2pIYNPnw0IPO/3AhT6yjnf8Mnv3ZC2A4eRYOjqrg+bfaXg9XHDRJDWQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.27.0", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "resolved": "http://localhost:4873/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/propagator-b3": { + "version": "1.30.1", + "resolved": "http://localhost:4873/@opentelemetry/propagator-b3/-/propagator-b3-1.30.1.tgz", + "integrity": "sha512-oATwWWDIJzybAZ4pO76ATN5N6FFbOA1otibAVlS8v90B4S1wClnhRUk7K+2CHAwN1JKYuj4jh/lpCEG5BAqFuQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.30.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/propagator-b3/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "resolved": "http://localhost:4873/@opentelemetry/core/-/core-1.30.1.tgz", + "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/propagator-b3/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "http://localhost:4873/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/propagator-jaeger": { + "version": "1.30.1", + "resolved": "http://localhost:4873/@opentelemetry/propagator-jaeger/-/propagator-jaeger-1.30.1.tgz", + "integrity": "sha512-Pj/BfnYEKIOImirH76M4hDaBSx6HyZ2CXUqk+Kj02m6BB80c/yo4BdWkn/1gDFfU+YPY+bPR2U0DKBfdxCKwmg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.30.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/propagator-jaeger/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "resolved": "http://localhost:4873/@opentelemetry/core/-/core-1.30.1.tgz", + "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/propagator-jaeger/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "http://localhost:4873/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "1.30.1", + "resolved": "http://localhost:4873/@opentelemetry/resources/-/resources-1.30.1.tgz", + "integrity": "sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.30.1", + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "resolved": "http://localhost:4873/@opentelemetry/core/-/core-1.30.1.tgz", + "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "http://localhost:4873/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.54.2", + "resolved": "http://localhost:4873/@opentelemetry/sdk-logs/-/sdk-logs-0.54.2.tgz", + "integrity": "sha512-yIbYqDLS/AtBbPjCjh6eSToGNRMqW2VR8RrKEy+G+J7dFG7pKoptTH5T+XlKPleP9NY8JZYIpgJBlI+Osi0rFw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.54.2", + "@opentelemetry/core": "1.27.0", + "@opentelemetry/resources": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/core": { + "version": "1.27.0", + "resolved": "http://localhost:4873/@opentelemetry/core/-/core-1.27.0.tgz", + "integrity": "sha512-yQPKnK5e+76XuiqUH/gKyS8wv/7qITd5ln56QkBTf3uggr0VkXOXfcaAuG330UfdYu83wsyoBwqwxigpIG+Jkg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": { + "version": "1.27.0", + "resolved": "http://localhost:4873/@opentelemetry/resources/-/resources-1.27.0.tgz", + "integrity": "sha512-jOwt2VJ/lUD5BLc+PMNymDrUCpm5PKi1E9oSVYAvz01U/VdndGmrtV3DU1pG4AwlYhJRHbHfOUIlpBeXCPw6QQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.27.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "resolved": "http://localhost:4873/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "1.27.0", + "resolved": "http://localhost:4873/@opentelemetry/sdk-metrics/-/sdk-metrics-1.27.0.tgz", + "integrity": "sha512-JzWgzlutoXCydhHWIbLg+r76m+m3ncqvkCcsswXAQ4gqKS+LOHKhq+t6fx1zNytvLuaOUBur7EvWxECc4jPQKg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.27.0", + "@opentelemetry/resources": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/core": { + "version": "1.27.0", + "resolved": "http://localhost:4873/@opentelemetry/core/-/core-1.27.0.tgz", + "integrity": "sha512-yQPKnK5e+76XuiqUH/gKyS8wv/7qITd5ln56QkBTf3uggr0VkXOXfcaAuG330UfdYu83wsyoBwqwxigpIG+Jkg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": { + "version": "1.27.0", + "resolved": "http://localhost:4873/@opentelemetry/resources/-/resources-1.27.0.tgz", + "integrity": "sha512-jOwt2VJ/lUD5BLc+PMNymDrUCpm5PKi1E9oSVYAvz01U/VdndGmrtV3DU1pG4AwlYhJRHbHfOUIlpBeXCPw6QQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.27.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "resolved": "http://localhost:4873/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "1.30.1", + "resolved": "http://localhost:4873/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.30.1.tgz", + "integrity": "sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.30.1", + "@opentelemetry/resources": "1.30.1", + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "resolved": "http://localhost:4873/@opentelemetry/core/-/core-1.30.1.tgz", + "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "http://localhost:4873/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/sdk-trace-node": { + "version": "1.30.1", + "resolved": "http://localhost:4873/@opentelemetry/sdk-trace-node/-/sdk-trace-node-1.30.1.tgz", + "integrity": "sha512-cBjYOINt1JxXdpw1e5MlHmFRc5fgj4GW/86vsKFxJCJ8AL4PdVtYH41gWwl4qd4uQjqEL1oJVrXkSy5cnduAnQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/context-async-hooks": "1.30.1", + "@opentelemetry/core": "1.30.1", + "@opentelemetry/propagator-b3": "1.30.1", + "@opentelemetry/propagator-jaeger": "1.30.1", + "@opentelemetry/sdk-trace-base": "1.30.1", + "semver": "^7.5.2" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-node/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "resolved": "http://localhost:4873/@opentelemetry/core/-/core-1.30.1.tgz", + "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-node/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "http://localhost:4873/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.40.0", + "resolved": "http://localhost:4873/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", + "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "http://localhost:4873/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -3205,6 +4986,13 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "http://localhost:4873/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT", + "peer": true + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "http://localhost:4873/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -3229,12 +5017,69 @@ "url": "https://opencollective.com/pkgr" } }, - "node_modules/@scarf/scarf": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", - "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", - "hasInstallScript": true, - "license": "Apache-2.0" + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "http://localhost:4873/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "http://localhost:4873/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "http://localhost:4873/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "http://localhost:4873/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "http://localhost:4873/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "http://localhost:4873/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "http://localhost:4873/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "http://localhost:4873/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "http://localhost:4873/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "http://localhost:4873/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" }, "node_modules/@sinclair/typebox": { "version": "0.34.48", @@ -3522,7 +5367,6 @@ "version": "7.0.15", "resolved": "http://localhost:4873/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, "license": "MIT" }, "node_modules/@types/methods": { @@ -3541,6 +5385,12 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pidusage": { + "version": "2.0.5", + "resolved": "http://localhost:4873/@types/pidusage/-/pidusage-2.0.5.tgz", + "integrity": "sha512-MIiyZI4/MK9UGUXWt0jJcCZhVw7YdhBuTOuqP/BjuLDLZ2PmmViMIQgZiWxtaMicQfAz/kMrZ5T7PKxFSkTeUA==", + "license": "MIT" + }, "node_modules/@types/qs": { "version": "6.15.0", "resolved": "http://localhost:4873/@types/qs/-/qs-6.15.0.tgz", @@ -3555,6 +5405,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "http://localhost:4873/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "http://localhost:4873/@types/send/-/send-1.2.1.tgz", @@ -3576,6 +5432,12 @@ "@types/node": "*" } }, + "node_modules/@types/shimmer": { + "version": "1.2.0", + "resolved": "http://localhost:4873/@types/shimmer/-/shimmer-1.2.0.tgz", + "integrity": "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==", + "license": "MIT" + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "http://localhost:4873/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -4375,6 +6237,18 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "http://localhost:4873/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "http://localhost:4873/accepts/-/accepts-2.0.0.tgz", @@ -4392,7 +6266,6 @@ "version": "8.16.0", "resolved": "http://localhost:4873/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -4401,6 +6274,15 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "http://localhost:4873/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8" + } + }, "node_modules/acorn-import-phases": { "version": "1.0.4", "resolved": "http://localhost:4873/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", @@ -4437,6 +6319,15 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "http://localhost:4873/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ai": { "version": "6.0.116", "resolved": "http://localhost:4873/ai/-/ai-6.0.116.tgz", @@ -4630,6 +6521,7 @@ "version": "2.0.1", "resolved": "http://localhost:4873/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, "license": "Python-2.0" }, "node_modules/array-timsort": { @@ -4646,12 +6538,26 @@ "dev": true, "license": "MIT" }, + "node_modules/async": { + "version": "0.2.10", + "resolved": "http://localhost:4873/async/-/async-0.2.10.tgz", + "integrity": "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "http://localhost:4873/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "http://localhost:4873/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/axios": { "version": "1.13.6", "resolved": "http://localhost:4873/axios/-/axios-1.13.6.tgz", @@ -4773,7 +6679,6 @@ "version": "1.5.1", "resolved": "http://localhost:4873/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -4812,6 +6717,15 @@ "node": ">=6.0.0" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "http://localhost:4873/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "http://localhost:4873/bl/-/bl-4.1.0.tgz", @@ -4848,6 +6762,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "http://localhost:4873/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "http://localhost:4873/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -4954,6 +6875,12 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "http://localhost:4873/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "http://localhost:4873/buffer-from/-/buffer-from-1.1.2.tgz", @@ -5029,6 +6956,48 @@ "node": ">=6" } }, + "node_modules/camelcase-keys": { + "version": "9.1.3", + "resolved": "http://localhost:4873/camelcase-keys/-/camelcase-keys-9.1.3.tgz", + "integrity": "sha512-Rircqi9ch8AnZscQcsA1C47NFdaO3wukpmIRzYcDOrmvgt78hM/sj5pZhZNec2NM12uk5vTwRHZ4anGcrC4ZTg==", + "license": "MIT", + "dependencies": { + "camelcase": "^8.0.0", + "map-obj": "5.0.0", + "quick-lru": "^6.1.1", + "type-fest": "^4.3.2" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-keys/node_modules/camelcase": { + "version": "8.0.0", + "resolved": "http://localhost:4873/camelcase/-/camelcase-8.0.0.tgz", + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-keys/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "http://localhost:4873/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001779", "resolved": "http://localhost:4873/caniuse-lite/-/caniuse-lite-1.0.30001779.tgz", @@ -5100,6 +7069,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "http://localhost:4873/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/chrome-trace-event": { "version": "1.0.4", "resolved": "http://localhost:4873/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", @@ -5175,69 +7153,6 @@ "@colors/colors": "1.5.0" } }, - "node_modules/cli-truncate": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", - "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "slice-ansi": "^8.0.0", - "string-width": "^8.2.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/cli-truncate/node_modules/string-width": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", - "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/cli-width": { "version": "4.1.0", "resolved": "http://localhost:4873/cli-width/-/cli-width-4.1.0.tgz", @@ -5340,9 +7255,8 @@ }, "node_modules/colorette": { "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "resolved": "http://localhost:4873/colorette/-/colorette-2.0.20.tgz", "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -5550,6 +7464,24 @@ "node": ">= 8" } }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "http://localhost:4873/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "http://localhost:4873/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "http://localhost:4873/debug/-/debug-4.4.3.tgz", @@ -5612,6 +7544,40 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "http://localhost:4873/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "http://localhost:4873/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "http://localhost:4873/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -5639,6 +7605,21 @@ "node": ">= 0.8" } }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "http://localhost:4873/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "http://localhost:4873/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "http://localhost:4873/detect-newline/-/detect-newline-3.1.0.tgz", @@ -5649,6 +7630,12 @@ "node": ">=8" } }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "http://localhost:4873/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "license": "MIT" + }, "node_modules/dezalgo": { "version": "1.0.4", "resolved": "http://localhost:4873/dezalgo/-/dezalgo-1.0.4.tgz", @@ -5730,6 +7717,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "http://localhost:4873/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "http://localhost:4873/ee-first/-/ee-first-1.1.1.tgz", @@ -5772,6 +7768,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "http://localhost:4873/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/engine.io": { "version": "6.6.6", "resolved": "http://localhost:4873/engine.io/-/engine.io-6.6.6.tgz", @@ -5859,19 +7864,6 @@ "node": ">=10.13.0" } }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "http://localhost:4873/error-ex/-/error-ex-1.3.4.tgz", @@ -5934,6 +7926,12 @@ "node": ">= 0.4" } }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "http://localhost:4873/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.4", "resolved": "http://localhost:4873/esbuild/-/esbuild-0.27.4.tgz", @@ -5996,7 +7994,6 @@ "version": "4.0.0", "resolved": "http://localhost:4873/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -6229,18 +8226,25 @@ "node": ">= 0.6" } }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "dev": true, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "http://localhost:4873/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter2": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz", + "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==", "license": "MIT" }, "node_modules/events": { "version": "3.3.0", "resolved": "http://localhost:4873/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.8.x" @@ -6357,6 +8361,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "http://localhost:4873/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-copy": { + "version": "3.0.2", + "resolved": "http://localhost:4873/fast-copy/-/fast-copy-3.0.2.tgz", + "integrity": "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "http://localhost:4873/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6385,6 +8401,15 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-redact": { + "version": "3.5.0", + "resolved": "http://localhost:4873/fast-redact/-/fast-redact-3.5.0.tgz", + "integrity": "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "http://localhost:4873/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", @@ -6436,6 +8461,29 @@ } } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "http://localhost:4873/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "http://localhost:4873/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -6539,6 +8587,32 @@ "dev": true, "license": "ISC" }, + "node_modules/fluent-ffmpeg": { + "version": "2.1.3", + "resolved": "http://localhost:4873/fluent-ffmpeg/-/fluent-ffmpeg-2.1.3.tgz", + "integrity": "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "dependencies": { + "async": "^0.2.9", + "which": "^1.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fluent-ffmpeg/node_modules/which": { + "version": "1.3.1", + "resolved": "http://localhost:4873/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "http://localhost:4873/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -6641,6 +8715,18 @@ "node": ">= 0.6" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "http://localhost:4873/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/formidable": { "version": "3.5.4", "resolved": "http://localhost:4873/formidable/-/formidable-3.5.4.tgz", @@ -6730,6 +8816,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gaxios": { + "version": "7.1.4", + "resolved": "http://localhost:4873/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "http://localhost:4873/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "http://localhost:4873/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -6750,19 +8864,6 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-east-asian-width": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", - "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "http://localhost:4873/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -6840,7 +8941,6 @@ "version": "13.0.0", "resolved": "http://localhost:4873/glob/-/glob-13.0.0.tgz", "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "minimatch": "^10.1.1", @@ -6878,7 +8978,6 @@ "version": "4.0.4", "resolved": "http://localhost:4873/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, "license": "MIT", "engines": { "node": "18 || 20 || >=22" @@ -6888,7 +8987,6 @@ "version": "5.0.4", "resolved": "http://localhost:4873/brace-expansion/-/brace-expansion-5.0.4.tgz", "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -6901,7 +8999,6 @@ "version": "10.2.4", "resolved": "http://localhost:4873/minimatch/-/minimatch-10.2.4.tgz", "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.2" @@ -6913,6 +9010,23 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "http://localhost:4873/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, "node_modules/globals": { "version": "16.5.0", "resolved": "http://localhost:4873/globals/-/globals-16.5.0.tgz", @@ -6926,6 +9040,48 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "http://localhost:4873/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "http://localhost:4873/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "http://localhost:4873/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "http://localhost:4873/gopd/-/gopd-1.2.0.tgz", @@ -6987,6 +9143,18 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "http://localhost:4873/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "http://localhost:4873/has-symbols/-/has-symbols-1.1.0.tgz", @@ -7014,6 +9182,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hash-it": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/hash-it/-/hash-it-6.0.1.tgz", + "integrity": "sha512-qhl8+l4Zwi1eLlL3lja5ywmDQnBzLEJxd0QJoAVIgZpgQbdtVZrN5ypB0y3VHwBlvAalpcbM2/A6x7oUks5zNg==", + "license": "MIT" + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "http://localhost:4873/hasown/-/hasown-2.0.2.tgz", @@ -7026,6 +9200,21 @@ "node": ">= 0.4" } }, + "node_modules/heap-js": { + "version": "2.7.1", + "resolved": "http://localhost:4873/heap-js/-/heap-js-2.7.1.tgz", + "integrity": "sha512-EQfezRg0NCZGNlhlDR3Evrw1FVL2G3LhU7EgPoxufQKruNBSYA8MiRPHeWbU+36o+Fhel0wMwM+sLEiBAlNLJA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "http://localhost:4873/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "license": "MIT" + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "http://localhost:4873/html-escaper/-/html-escaper-2.0.2.tgz", @@ -7053,6 +9242,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "http://localhost:4873/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "http://localhost:4873/human-signals/-/human-signals-2.1.0.tgz", @@ -7063,22 +9265,6 @@ "node": ">=10.17.0" } }, - "node_modules/husky": { - "version": "9.1.7", - "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", - "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", - "dev": true, - "license": "MIT", - "bin": { - "husky": "bin.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/typicode" - } - }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "http://localhost:4873/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -7142,6 +9328,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-in-the-middle": { + "version": "1.15.0", + "resolved": "http://localhost:4873/import-in-the-middle/-/import-in-the-middle-1.15.0.tgz", + "integrity": "sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==", + "license": "Apache-2.0", + "dependencies": { + "acorn": "^8.14.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^1.2.2", + "module-details-from-path": "^1.0.3" + } + }, + "node_modules/import-in-the-middle/node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "http://localhost:4873/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "license": "MIT" + }, "node_modules/import-local": { "version": "3.2.0", "resolved": "http://localhost:4873/import-local/-/import-local-3.2.0.tgz", @@ -7230,6 +9434,21 @@ "dev": true, "license": "MIT" }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "http://localhost:4873/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "http://localhost:4873/is-extglob/-/is-extglob-2.1.1.tgz", @@ -7329,7 +9548,6 @@ "version": "2.0.0", "resolved": "http://localhost:4873/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -8211,6 +10429,24 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "http://localhost:4873/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "http://localhost:4873/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "http://localhost:4873/js-tokens/-/js-tokens-4.0.0.tgz", @@ -8222,6 +10458,7 @@ "version": "4.1.1", "resolved": "http://localhost:4873/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -8243,6 +10480,15 @@ "node": ">=6" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "http://localhost:4873/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "http://localhost:4873/json-buffer/-/json-buffer-3.0.1.tgz", @@ -8257,6 +10503,27 @@ "dev": true, "license": "MIT" }, + "node_modules/json-rules-engine": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/json-rules-engine/-/json-rules-engine-6.6.0.tgz", + "integrity": "sha512-jJ4eVCPnItetPiU3fTIzrrl3d2zeIXCcCy11dwWhN72YXBR2mByV1Vfbrvt6y2n+VFmxc6rtL/XhDqLKIwBx6g==", + "license": "ISC", + "dependencies": { + "clone": "^2.1.2", + "eventemitter2": "^6.4.4", + "hash-it": "^6.0.0", + "jsonpath-plus": "^7.2.0" + } + }, + "node_modules/json-rules-engine/node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/json-schema": { "version": "0.4.0", "resolved": "http://localhost:4873/json-schema/-/json-schema-0.4.0.tgz", @@ -8277,6 +10544,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "http://localhost:4873/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "http://localhost:4873/json5/-/json5-2.2.3.tgz", @@ -8310,6 +10583,45 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonpath-plus": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-7.2.0.tgz", + "integrity": "sha512-zBfiUPM5nD0YZSBT/o/fbCUlCcepMIdP0CJZxM1+KgA4f2T206f6VAg9e7mX35+KlMaIc5qXW34f3BnwJ3w+RA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "http://localhost:4873/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "http://localhost:4873/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/kafkajs": { + "version": "2.2.4", + "resolved": "http://localhost:4873/kafkajs/-/kafkajs-2.2.4.tgz", + "integrity": "sha512-j/YeapB1vfPT2iOIUn/vxdyKEuhuY2PxMBvf5JWux6iSaukAccrMtXEY/Lb7OvavDhOWME589bpLrEdnVHjfjA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "http://localhost:4873/keyv/-/keyv-4.5.4.tgz", @@ -8351,154 +10663,19 @@ "dev": true, "license": "MIT" }, - "node_modules/lint-staged": { - "version": "16.4.0", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz", - "integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==", - "dev": true, - "license": "MIT", + "node_modules/livekit-server-sdk": { + "version": "2.15.0", + "resolved": "http://localhost:4873/livekit-server-sdk/-/livekit-server-sdk-2.15.0.tgz", + "integrity": "sha512-HmzjWnwEwwShu8yUf7VGFXdc+BuMJR5pnIY4qsdlhqI9d9wDgq+4cdTEHg0NEBaiGnc6PCOBiaTYgmIyVJ0S9w==", + "license": "Apache-2.0", "dependencies": { - "commander": "^14.0.3", - "listr2": "^9.0.5", - "picomatch": "^4.0.3", - "string-argv": "^0.3.2", - "tinyexec": "^1.0.4", - "yaml": "^2.8.2" - }, - "bin": { - "lint-staged": "bin/lint-staged.js" - }, - "engines": { - "node": ">=20.17" - }, - "funding": { - "url": "https://opencollective.com/lint-staged" - } - }, - "node_modules/lint-staged/node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/lint-staged/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/listr2": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", - "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "cli-truncate": "^5.0.0", - "colorette": "^2.0.20", - "eventemitter3": "^5.0.1", - "log-update": "^6.1.0", - "rfdc": "^1.4.1", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/listr2/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/listr2/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/listr2/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, - "node_modules/listr2/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" + "@bufbuild/protobuf": "^1.10.1", + "@livekit/protocol": "^1.43.1", + "camelcase-keys": "^9.0.0", + "jose": "^5.1.2" }, "engines": { "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/listr2/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/listr2/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/load-esm": { @@ -8599,208 +10776,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/log-update": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", - "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-escapes": "^7.0.0", - "cli-cursor": "^5.0.0", - "slice-ansi": "^7.1.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/ansi-escapes": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", - "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/log-update/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/log-update/node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, - "node_modules/log-update/node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/log-update/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/log-update/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } + "node_modules/long": { + "version": "5.3.2", + "resolved": "http://localhost:4873/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" }, "node_modules/lru-cache": { "version": "5.1.1", @@ -8855,6 +10835,30 @@ "tmpl": "1.0.5" } }, + "node_modules/map-obj": { + "version": "5.0.0", + "resolved": "http://localhost:4873/map-obj/-/map-obj-5.0.0.tgz", + "integrity": "sha512-2L3MIgJynYrZ3TYMriLDLWocz15okFakV6J12HXvMXDHui2x/zgChzg1u9mFFGbbGWE+GsLpQByt4POb9Or+uA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "http://localhost:4873/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "http://localhost:4873/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -8990,19 +10994,6 @@ "node": ">=6" } }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "http://localhost:4873/minimatch/-/minimatch-3.1.5.tgz", @@ -9020,7 +11011,6 @@ "version": "1.2.8", "resolved": "http://localhost:4873/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -9030,12 +11020,29 @@ "version": "7.1.3", "resolved": "http://localhost:4873/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "http://localhost:4873/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "http://localhost:4873/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "http://localhost:4873/ms/-/ms-2.1.3.tgz", @@ -9160,6 +11167,26 @@ "dev": true, "license": "MIT" }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "http://localhost:4873/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-emoji": { "version": "1.11.0", "resolved": "http://localhost:4873/node-emoji/-/node-emoji-1.11.0.tgz", @@ -9170,6 +11197,30 @@ "lodash": "^4.17.21" } }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "http://localhost:4873/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "http://localhost:4873/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "license": "MIT" + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "http://localhost:4873/node-int64/-/node-int64-0.4.0.tgz", @@ -9237,6 +11288,35 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "http://localhost:4873/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ofetch": { + "version": "1.5.1", + "resolved": "http://localhost:4873/ofetch/-/ofetch-1.5.1.tgz", + "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", + "license": "MIT", + "dependencies": { + "destr": "^2.0.5", + "node-fetch-native": "^1.6.7", + "ufo": "^1.6.1" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "http://localhost:4873/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "http://localhost:4873/on-finished/-/on-finished-2.4.1.tgz", @@ -9274,6 +11354,50 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/onnxruntime-common": { + "version": "1.21.0", + "resolved": "http://localhost:4873/onnxruntime-common/-/onnxruntime-common-1.21.0.tgz", + "integrity": "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==", + "license": "MIT" + }, + "node_modules/onnxruntime-node": { + "version": "1.21.0", + "resolved": "http://localhost:4873/onnxruntime-node/-/onnxruntime-node-1.21.0.tgz", + "integrity": "sha512-NeaCX6WW2L8cRCSqy3bInlo5ojjQqu2fD3D+9W5qb5irwxhEyWKXeH2vZ8W9r6VxaMPUan+4/7NDwZMtouZxEw==", + "hasInstallScript": true, + "license": "MIT", + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "global-agent": "^3.0.0", + "onnxruntime-common": "1.21.0", + "tar": "^7.0.1" + } + }, + "node_modules/openai": { + "version": "6.32.0", + "resolved": "http://localhost:4873/openai/-/openai-6.32.0.tgz", + "integrity": "sha512-j3k+BjydAf8yQlcOI7WUQMQTbbF5GEIMAE2iZYCOzwwB3S2pCheaWYp+XZRNAch4jWVc52PMDGRRjutao3lLCg==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "http://localhost:4873/optionator/-/optionator-0.9.4.tgz", @@ -9348,6 +11472,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "http://localhost:4873/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/p-try": { "version": "2.2.0", "resolved": "http://localhost:4873/p-try/-/p-try-2.2.0.tgz", @@ -9436,11 +11573,16 @@ "node": ">=8" } }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "http://localhost:4873/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, "node_modules/path-scurry": { "version": "2.0.2", "resolved": "http://localhost:4873/path-scurry/-/path-scurry-2.0.2.tgz", "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^11.0.0", @@ -9457,7 +11599,6 @@ "version": "11.2.7", "resolved": "http://localhost:4873/lru-cache/-/lru-cache-11.2.7.tgz", "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -9503,6 +11644,179 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pidusage": { + "version": "4.0.1", + "resolved": "http://localhost:4873/pidusage/-/pidusage-4.0.1.tgz", + "integrity": "sha512-yCH2dtLHfEBnzlHUJymR/Z1nN2ePG3m392Mv8TFlTP1B0xkpMQNHAnfkY0n2tAi6ceKO6YWhxYfZ96V4vVkh/g==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/pino": { + "version": "8.21.0", + "resolved": "http://localhost:4873/pino/-/pino-8.21.0.tgz", + "integrity": "sha512-ip4qdzjkAyDDZklUaZkcRFb2iA118H9SgRh8yzTkSQK8HilsOJF7rSY8HoW5+I0M46AZgX/pxbprf2vvzQCE0Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0", + "fast-redact": "^3.1.1", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^1.2.0", + "pino-std-serializers": "^6.0.0", + "process-warning": "^3.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^3.7.0", + "thread-stream": "^2.6.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "1.2.0", + "resolved": "http://localhost:4873/pino-abstract-transport/-/pino-abstract-transport-1.2.0.tgz", + "integrity": "sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==", + "license": "MIT", + "dependencies": { + "readable-stream": "^4.0.0", + "split2": "^4.0.0" + } + }, + "node_modules/pino-abstract-transport/node_modules/buffer": { + "version": "6.0.3", + "resolved": "http://localhost:4873/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/pino-abstract-transport/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "http://localhost:4873/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "11.3.0", + "resolved": "http://localhost:4873/pino-pretty/-/pino-pretty-11.3.0.tgz", + "integrity": "sha512-oXwn7ICywaZPHmu3epHGU2oJX4nPmKvHvB/bwrJHlGcbEWaVcotkpyVHMKLKmiVryWYByNp0jpgAcXpFJDXJzA==", + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^3.0.2", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pump": "^3.0.0", + "readable-stream": "^4.0.0", + "secure-json-parse": "^2.4.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^3.1.1" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-pretty/node_modules/buffer": { + "version": "6.0.3", + "resolved": "http://localhost:4873/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/pino-pretty/node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "http://localhost:4873/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "http://localhost:4873/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/pino-pretty/node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "http://localhost:4873/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "6.2.2", + "resolved": "http://localhost:4873/pino-std-serializers/-/pino-std-serializers-6.2.2.tgz", + "integrity": "sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA==", + "license": "MIT" + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "http://localhost:4873/pirates/-/pirates-4.0.7.tgz", @@ -9659,6 +11973,45 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "http://localhost:4873/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-warning": { + "version": "3.0.0", + "resolved": "http://localhost:4873/process-warning/-/process-warning-3.0.0.tgz", + "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==", + "license": "MIT" + }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "http://localhost:4873/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "http://localhost:4873/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -9678,6 +12031,16 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "http://localhost:4873/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "http://localhost:4873/punycode/-/punycode-2.3.1.tgz", @@ -9720,6 +12083,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "http://localhost:4873/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/quick-lru": { + "version": "6.1.2", + "resolved": "http://localhost:4873/quick-lru/-/quick-lru-6.1.2.tgz", + "integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "http://localhost:4873/range-parser/-/range-parser-1.2.1.tgz", @@ -9779,6 +12160,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "http://localhost:4873/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/redis-errors": { "version": "1.2.0", "resolved": "http://localhost:4873/redis-errors/-/redis-errors-1.2.0.tgz", @@ -9826,6 +12216,40 @@ "node": ">=0.10.0" } }, + "node_modules/require-in-the-middle": { + "version": "7.5.2", + "resolved": "http://localhost:4873/require-in-the-middle/-/require-in-the-middle-7.5.2.tgz", + "integrity": "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3", + "resolve": "^1.22.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "http://localhost:4873/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/resolve-cwd": { "version": "3.0.0", "resolved": "http://localhost:4873/resolve-cwd/-/resolve-cwd-3.0.0.tgz", @@ -9890,12 +12314,37 @@ "dev": true, "license": "ISC" }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "license": "MIT" + "node_modules/retry": { + "version": "0.13.1", + "resolved": "http://localhost:4873/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "http://localhost:4873/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/roarr/node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "http://localhost:4873/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause" }, "node_modules/router": { "version": "2.2.0", @@ -9942,6 +12391,15 @@ ], "license": "MIT" }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "http://localhost:4873/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "http://localhost:4873/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -9967,11 +12425,16 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "http://localhost:4873/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.7.4", "resolved": "http://localhost:4873/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -9980,6 +12443,12 @@ "node": ">=10" } }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "http://localhost:4873/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "license": "MIT" + }, "node_modules/send": { "version": "1.2.1", "resolved": "http://localhost:4873/send/-/send-1.2.1.tgz", @@ -10006,6 +12475,33 @@ "url": "https://opencollective.com/express" } }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "http://localhost:4873/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/serialize-error/node_modules/type-fest": { + "version": "0.13.1", + "resolved": "http://localhost:4873/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/serve-static": { "version": "2.2.1", "resolved": "http://localhost:4873/serve-static/-/serve-static-2.2.1.tgz", @@ -10031,6 +12527,50 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "http://localhost:4873/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "http://localhost:4873/shebang-command/-/shebang-command-2.0.0.tgz", @@ -10054,6 +12594,12 @@ "node": ">=8" } }, + "node_modules/shimmer": { + "version": "1.2.1", + "resolved": "http://localhost:4873/shimmer/-/shimmer-1.2.1.tgz", + "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==", + "license": "BSD-2-Clause" + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "http://localhost:4873/side-channel/-/side-channel-1.1.0.tgz", @@ -10149,52 +12695,6 @@ "node": ">=8" } }, - "node_modules/slice-ansi": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", - "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.3", - "is-fullwidth-code-point": "^5.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/socket.io": { "version": "4.8.3", "resolved": "http://localhost:4873/socket.io/-/socket.io-4.8.3.tgz", @@ -10279,6 +12779,15 @@ "node": ">= 0.6" } }, + "node_modules/sonic-boom": { + "version": "3.8.1", + "resolved": "http://localhost:4873/sonic-boom/-/sonic-boom-3.8.1.tgz", + "integrity": "sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.7.4", "resolved": "http://localhost:4873/source-map/-/source-map-0.7.4.tgz", @@ -10310,6 +12819,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "http://localhost:4873/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "http://localhost:4873/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -10372,16 +12890,6 @@ "safe-buffer": "~5.2.0" } }, - "node_modules/string-argv": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", - "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6.19" - } - }, "node_modules/string-length": { "version": "4.0.2", "resolved": "http://localhost:4873/string-length/-/string-length-4.0.2.tgz", @@ -10478,7 +12986,6 @@ "version": "3.1.1", "resolved": "http://localhost:4873/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -10552,13 +13059,16 @@ "node": ">=8" } }, - "node_modules/swagger-ui-dist": { - "version": "5.31.0", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.31.0.tgz", - "integrity": "sha512-zSUTIck02fSga6rc0RZP3b7J7wgHXwLea8ZjgLA3Vgnb8QeOl3Wou2/j5QkzSGeoz6HusP/coYuJl33aQxQZpg==", - "license": "Apache-2.0", - "dependencies": { - "@scarf/scarf": "=1.4.0" + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "http://localhost:4873/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/symbol-observable": { @@ -10601,6 +13111,31 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar": { + "version": "7.5.13", + "resolved": "http://localhost:4873/tar/-/tar-7.5.13.tgz", + "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "http://localhost:4873/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/terser": { "version": "5.46.1", "resolved": "http://localhost:4873/terser/-/terser-5.46.1.tgz", @@ -10804,14 +13339,13 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/tinyexec": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", - "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", - "dev": true, + "node_modules/thread-stream": { + "version": "2.7.0", + "resolved": "http://localhost:4873/thread-stream/-/thread-stream-2.7.0.tgz", + "integrity": "sha512-qQiRWsU/wvNolI6tbbCKd9iKaTnCXsTwVxhhKM6nctPdujTyztjlbUkUTUymidWcMnZ5pWR0ej4a0tjsW021vw==", "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "real-require": "^0.2.0" } }, "node_modules/tinyglobby": { @@ -11162,7 +13696,6 @@ "version": "5.9.3", "resolved": "http://localhost:4873/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -11196,6 +13729,12 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "http://localhost:4873/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "license": "MIT" + }, "node_modules/uglify-js": { "version": "3.19.3", "resolved": "http://localhost:4873/uglify-js/-/uglify-js-3.19.3.tgz", @@ -11341,6 +13880,19 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "http://localhost:4873/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "http://localhost:4873/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -11406,6 +13958,15 @@ "defaults": "^1.0.3" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "http://localhost:4873/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/webpack": { "version": "5.105.4", "resolved": "http://localhost:4873/webpack/-/webpack-5.105.4.tgz", @@ -11732,22 +14293,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "dev": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yargs": { "version": "17.7.2", "resolved": "http://localhost:4873/yargs/-/yargs-17.7.2.tgz", @@ -11818,10 +14363,18 @@ "resolved": "http://localhost:4873/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "http://localhost:4873/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } } } } diff --git a/package.json b/package.json index bef6ba1..2fcf001 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/ai/ai-chat.controller.ts b/src/ai/ai-chat.controller.ts index c96738d..6964943 100644 --- a/src/ai/ai-chat.controller.ts +++ b/src/ai/ai-chat.controller.ts @@ -1,99 +1,506 @@ -import { - Controller, - Post, - Body, - Headers, - HttpException, - Logger, -} from '@nestjs/common'; +import { Controller, Post, Body, Headers, Req, Res, HttpException, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { generateText, tool, stepCountIs } from 'ai'; +import type { Request, Response } from 'express'; +import { generateText, streamText, tool, stepCountIs } from 'ai'; import type { LanguageModel } from 'ai'; import { z } from 'zod'; import { PlatformGraphqlService } from '../platform/platform-graphql.service'; import { createAiModel, isAiConfigured } from './ai-provider'; type ChatRequest = { - message: string; - context?: { callerPhone?: string; leadId?: string; leadName?: string }; + message: string; + context?: { callerPhone?: string; leadId?: string; leadName?: string }; }; @Controller('api/ai') export class AiChatController { - private readonly logger = new Logger(AiChatController.name); - private readonly aiModel: LanguageModel | null; - private knowledgeBase: string | null = null; - private kbLoadedAt = 0; - private readonly kbTtlMs = 5 * 60 * 1000; + private readonly logger = new Logger(AiChatController.name); + private readonly aiModel: LanguageModel | null; + private knowledgeBase: string | null = null; + private kbLoadedAt = 0; + private readonly kbTtlMs = 5 * 60 * 1000; - constructor( - private config: ConfigService, - private platform: PlatformGraphqlService, - ) { - this.aiModel = createAiModel(config); - if (!this.aiModel) { - this.logger.warn('AI not configured — chat uses fallback'); - } else { - const provider = config.get('ai.provider') ?? 'openai'; - const model = config.get('ai.model') ?? 'gpt-4o-mini'; - this.logger.log(`AI configured: ${provider}/${model}`); - } - } - - @Post('chat') - async chat( - @Body() body: ChatRequest, - @Headers('authorization') auth: string, - ) { - if (!auth) throw new HttpException('Authorization required', 401); - if (!body.message?.trim()) throw new HttpException('message required', 400); - - const msg = body.message.trim(); - const ctx = body.context; - let prefix = ''; - if (ctx) { - const parts: string[] = []; - if (ctx.leadName) parts.push(`Caller: ${ctx.leadName}`); - if (ctx.callerPhone) parts.push(`Phone: ${ctx.callerPhone}`); - if (ctx.leadId) parts.push(`Lead ID: ${ctx.leadId}`); - if (parts.length) prefix = `[Active call: ${parts.join(', ')}]\n\n`; + constructor( + private config: ConfigService, + private platform: PlatformGraphqlService, + ) { + this.aiModel = createAiModel(config); + if (!this.aiModel) { + this.logger.warn('AI not configured — chat uses fallback'); + } else { + const provider = config.get('ai.provider') ?? 'openai'; + const model = config.get('ai.model') ?? 'gpt-4o-mini'; + this.logger.log(`AI configured: ${provider}/${model}`); + } } - if (!this.aiModel) { - return { - reply: await this.fallback(msg, auth), - sources: ['fallback'], - confidence: 'low', - }; + @Post('chat') + async chat(@Body() body: ChatRequest, @Headers('authorization') auth: string) { + if (!auth) throw new HttpException('Authorization required', 401); + if (!body.message?.trim()) throw new HttpException('message required', 400); + + const msg = body.message.trim(); + const ctx = body.context; + let prefix = ''; + if (ctx) { + const parts: string[] = []; + if (ctx.leadName) parts.push(`Caller: ${ctx.leadName}`); + if (ctx.callerPhone) parts.push(`Phone: ${ctx.callerPhone}`); + if (ctx.leadId) parts.push(`Lead ID: ${ctx.leadId}`); + if (parts.length) prefix = `[Active call: ${parts.join(', ')}]\n\n`; + } + + if (!this.aiModel) { + return { reply: await this.fallback(msg, auth), sources: ['fallback'], confidence: 'low' }; + } + + try { + return await this.chatWithTools(`${prefix}${msg}`, auth); + } catch (err) { + this.logger.error(`AI chat error: ${err}`); + return { reply: await this.fallback(msg, auth), sources: ['fallback'], confidence: 'low' }; + } } - try { - return await this.chatWithTools(`${prefix}${msg}`, auth); - } catch (err) { - this.logger.error(`AI chat error: ${err}`); - return { - reply: await this.fallback(msg, auth), - sources: ['fallback'], - confidence: 'low', - }; - } - } + @Post('stream') + async stream(@Req() req: Request, @Res() res: Response, @Headers('authorization') auth: string) { + if (!auth) throw new HttpException('Authorization required', 401); - private async buildKnowledgeBase(auth: string): Promise { - const now = Date.now(); - if (this.knowledgeBase && now - this.kbLoadedAt < this.kbTtlMs) { - this.logger.log( - `KB cache hit (${this.knowledgeBase.length} chars, age ${Math.round((now - this.kbLoadedAt) / 1000)}s)`, - ); - return this.knowledgeBase; + const body = req.body; + const messages = body.messages ?? []; + if (!messages.length) throw new HttpException('messages required', 400); + + if (!this.aiModel) { + res.status(500).json({ error: 'AI not configured' }); + return; + } + + const ctx = body.context; + let systemPrompt: string; + + // Rules engine context — use rules-specific system prompt + if (ctx?.type === 'rules-engine') { + systemPrompt = this.buildRulesSystemPrompt(ctx.currentConfig); + } else if (ctx?.type === 'supervisor') { + systemPrompt = this.buildSupervisorSystemPrompt(); + } else { + const kb = await this.buildKnowledgeBase(auth); + systemPrompt = this.buildSystemPrompt(kb); + + // Inject caller context so the AI knows who is selected + if (ctx) { + const parts: string[] = []; + if (ctx.leadName) parts.push(`Currently viewing/talking to: ${ctx.leadName}`); + if (ctx.callerPhone) parts.push(`Phone: ${ctx.callerPhone}`); + if (ctx.leadId) parts.push(`Lead ID: ${ctx.leadId}`); + if (parts.length) { + systemPrompt += `\n\nCURRENT CONTEXT:\n${parts.join('\n')}\nUse this context to answer questions about "this patient" or "this caller" without asking for their name.`; + } + } + } + + const platformService = this.platform; + const isSupervisor = ctx?.type === 'supervisor'; + + // Supervisor tools — agent performance, campaign stats, team metrics + const supervisorTools = { + get_agent_performance: tool({ + description: 'Get performance metrics for all agents or a specific agent. Returns call counts, conversion rates, idle time, NPS scores.', + inputSchema: z.object({ + agentName: z.string().optional().describe('Agent name to look up. Leave empty for all agents.'), + }), + execute: async ({ agentName }) => { + const [callsData, leadsData, agentsData, followUpsData] = await Promise.all([ + platformService.queryWithAuth( + `{ calls(first: 200, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { id direction callStatus agentName startedAt durationSec disposition } } } }`, + undefined, auth, + ), + platformService.queryWithAuth( + `{ leads(first: 200) { edges { node { id assignedAgent status } } } }`, + undefined, auth, + ), + platformService.queryWithAuth( + `{ agents(first: 20) { edges { node { id name ozonetelagentid npsscore maxidleminutes minnpsthreshold minconversionpercent } } } }`, + undefined, auth, + ), + platformService.queryWithAuth( + `{ followUps(first: 100) { edges { node { id assignedAgent status } } } }`, + undefined, auth, + ), + ]); + + const calls = callsData.calls.edges.map((e: any) => e.node); + const leads = leadsData.leads.edges.map((e: any) => e.node); + const agents = agentsData.agents.edges.map((e: any) => e.node); + const followUps = followUpsData.followUps.edges.map((e: any) => e.node); + + const agentMetrics = agents + .filter((a: any) => !agentName || a.name.toLowerCase().includes(agentName.toLowerCase())) + .map((agent: any) => { + const agentCalls = calls.filter((c: any) => c.agentName === agent.name || c.agentName === agent.ozonetelagentid); + const totalCalls = agentCalls.length; + const missed = agentCalls.filter((c: any) => c.callStatus === 'MISSED').length; + const completed = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length; + const apptBooked = agentCalls.filter((c: any) => c.disposition === 'APPOINTMENT_BOOKED').length; + const agentLeads = leads.filter((l: any) => l.assignedAgent === agent.name); + const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === agent.name); + const pendingFollowUps = agentFollowUps.filter((f: any) => f.status === 'PENDING' || f.status === 'OVERDUE').length; + const conversionRate = totalCalls > 0 ? Math.round((apptBooked / totalCalls) * 100) : 0; + + return { + name: agent.name, + totalCalls, + completed, + missed, + appointmentsBooked: apptBooked, + conversionRate: `${conversionRate}%`, + assignedLeads: agentLeads.length, + pendingFollowUps, + npsScore: agent.npsscore, + maxIdleMinutes: agent.maxidleminutes, + minNpsThreshold: agent.minnpsthreshold, + minConversionPercent: agent.minconversionpercent, + belowNpsThreshold: agent.minnpsthreshold && (agent.npsscore ?? 100) < agent.minnpsthreshold, + belowConversionThreshold: agent.minconversionpercent && conversionRate < agent.minconversionpercent, + }; + }); + + return { agents: agentMetrics, totalAgents: agentMetrics.length }; + }, + }), + + get_campaign_stats: tool({ + description: 'Get campaign performance stats — lead counts, conversion rates, sources.', + inputSchema: z.object({}), + execute: async () => { + const [campaignsData, leadsData] = await Promise.all([ + platformService.queryWithAuth( + `{ campaigns(first: 20) { edges { node { id campaignName campaignStatus platform leadCount convertedCount budget { amountMicros } } } } }`, + undefined, auth, + ), + platformService.queryWithAuth( + `{ leads(first: 200) { edges { node { id campaignId status } } } }`, + undefined, auth, + ), + ]); + + const campaigns = campaignsData.campaigns.edges.map((e: any) => e.node); + const leads = leadsData.leads.edges.map((e: any) => e.node); + + return { + campaigns: campaigns.map((c: any) => { + const campaignLeads = leads.filter((l: any) => l.campaignId === c.id); + const converted = campaignLeads.filter((l: any) => l.status === 'CONVERTED' || l.status === 'APPOINTMENT_SET').length; + return { + name: c.campaignName, + status: c.campaignStatus, + platform: c.platform, + totalLeads: campaignLeads.length, + converted, + conversionRate: campaignLeads.length > 0 ? `${Math.round((converted / campaignLeads.length) * 100)}%` : '0%', + budget: c.budget ? `₹${c.budget.amountMicros / 1_000_000}` : null, + }; + }), + }; + }, + }), + + get_call_summary: tool({ + description: 'Get aggregate call statistics — total calls, inbound/outbound split, missed call rate, average duration, disposition breakdown.', + inputSchema: z.object({ + period: z.string().optional().describe('Period: "today", "week", "month". Defaults to "week".'), + }), + execute: async ({ period }) => { + const data = await platformService.queryWithAuth( + `{ calls(first: 500, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { id direction callStatus agentName startedAt durationSec disposition } } } }`, + undefined, auth, + ); + const allCalls = data.calls.edges.map((e: any) => e.node); + + // Filter by period + const now = new Date(); + const start = new Date(now); + if (period === 'today') start.setHours(0, 0, 0, 0); + else if (period === 'month') start.setDate(start.getDate() - 30); + else start.setDate(start.getDate() - 7); // default week + + const calls = allCalls.filter((c: any) => c.startedAt && new Date(c.startedAt) >= start); + + const total = calls.length; + const inbound = calls.filter((c: any) => c.direction === 'INBOUND').length; + const outbound = total - inbound; + const missed = calls.filter((c: any) => c.callStatus === 'MISSED').length; + const completed = calls.filter((c: any) => c.callStatus === 'COMPLETED').length; + const totalDuration = calls.reduce((sum: number, c: any) => sum + (c.durationSec ?? 0), 0); + const avgDuration = completed > 0 ? Math.round(totalDuration / completed) : 0; + + const dispositions: Record = {}; + for (const c of calls) { + if (c.disposition) dispositions[c.disposition] = (dispositions[c.disposition] ?? 0) + 1; + } + + return { + period: period ?? 'week', + total, + inbound, + outbound, + missed, + completed, + missedRate: total > 0 ? `${Math.round((missed / total) * 100)}%` : '0%', + avgDurationSeconds: avgDuration, + dispositions, + }; + }, + }), + + get_sla_breaches: tool({ + description: 'Get calls/leads that have breached their SLA — items that were not handled within the expected timeframe.', + inputSchema: z.object({}), + execute: async () => { + const data = await platformService.queryWithAuth( + `{ calls(first: 100, filter: { callStatus: { eq: MISSED }, callbackstatus: { eq: PENDING_CALLBACK } }) { edges { node { id callerNumber { primaryPhoneNumber } startedAt agentName sla } } } }`, + undefined, auth, + ); + const breached = data.calls.edges + .map((e: any) => e.node) + .filter((c: any) => (c.sla ?? 0) > 100); + + return { + breachedCount: breached.length, + items: breached.map((c: any) => ({ + id: c.id, + phone: c.callerNumber?.primaryPhoneNumber ?? 'Unknown', + slaPercent: c.sla, + missedAt: c.startedAt, + agent: c.agentName, + })), + }; + }, + }), + }; + + // Agent tools — patient lookup, appointments, doctors + const agentTools = { + lookup_patient: tool({ + description: 'Search for a patient/lead by phone number or name. Returns their profile, lead status, AI summary.', + inputSchema: z.object({ + phone: z.string().optional().describe('Phone number to search'), + name: z.string().optional().describe('Patient/lead name to search'), + }), + execute: async ({ phone, name }) => { + const data = await platformService.queryWithAuth( + `{ leads(first: 50) { edges { node { + id name contactName { firstName lastName } + contactPhone { primaryPhoneNumber } + source status interestedService + contactAttempts lastContacted + aiSummary aiSuggestedAction patientId + } } } }`, + undefined, auth, + ); + const leads = data.leads.edges.map((e: any) => e.node); + const phoneClean = (phone ?? '').replace(/\D/g, ''); + const nameClean = (name ?? '').toLowerCase(); + + const matched = leads.filter((l: any) => { + if (phoneClean) { + const lp = (l.contactPhone?.primaryPhoneNumber ?? '').replace(/\D/g, ''); + if (lp.endsWith(phoneClean) || phoneClean.endsWith(lp)) return true; + } + if (nameClean) { + const fn = `${l.contactName?.firstName ?? ''} ${l.contactName?.lastName ?? ''}`.toLowerCase(); + if (fn.includes(nameClean)) return true; + } + return false; + }); + + if (!matched.length) return { found: false, message: 'No patient/lead found.' }; + return { found: true, count: matched.length, leads: matched }; + }, + }), + + lookup_appointments: tool({ + description: 'Get appointments for a patient. Returns doctor, department, date, status.', + inputSchema: z.object({ + patientId: z.string().describe('Patient ID'), + }), + execute: async ({ patientId }) => { + const data = await platformService.queryWithAuth( + `{ appointments(first: 20, filter: { patientId: { eq: "${patientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node { + id scheduledAt status doctorName department reasonForVisit + } } } }`, + undefined, auth, + ); + return { appointments: data.appointments.edges.map((e: any) => e.node) }; + }, + }), + + lookup_doctor: tool({ + description: 'Get doctor details — schedule, clinic, fees, specialty.', + inputSchema: z.object({ + doctorName: z.string().describe('Doctor name'), + }), + execute: async ({ doctorName }) => { + const data = await platformService.queryWithAuth( + `{ doctors(first: 10) { edges { node { + id fullName { firstName lastName } + department specialty visitingHours + consultationFeeNew { amountMicros currencyCode } + clinic { clinicName } + } } } }`, + undefined, auth, + ); + const doctors = data.doctors.edges.map((e: any) => e.node); + // Strip "Dr." prefix and search flexibly + const search = doctorName.toLowerCase().replace(/^dr\.?\s*/i, '').trim(); + const searchWords = search.split(/\s+/); + const matched = doctors.filter((d: any) => { + const fn = (d.fullName?.firstName ?? '').toLowerCase(); + const ln = (d.fullName?.lastName ?? '').toLowerCase(); + const full = `${fn} ${ln}`; + return searchWords.some(w => w.length > 1 && (fn.includes(w) || ln.includes(w) || full.includes(w))); + }); + this.logger.log(`[TOOL] lookup_doctor: search="${doctorName}" → ${matched.length} results`); + if (!matched.length) return { found: false, message: `No doctor matching "${doctorName}". Available: ${doctors.map((d: any) => `Dr. ${d.fullName?.lastName ?? d.fullName?.firstName}`).join(', ')}` }; + return { found: true, doctors: matched }; + }, + }), + + book_appointment: tool({ + description: 'Book an appointment for a patient. Collect patient name, phone, department, doctor, preferred date/time, and reason before calling this.', + inputSchema: z.object({ + patientName: z.string().describe('Full name of the patient'), + phoneNumber: z.string().describe('Patient phone number'), + department: z.string().describe('Department for the appointment'), + doctorName: z.string().describe('Doctor name'), + scheduledAt: z.string().describe('Date and time in ISO format (e.g. 2026-04-01T10:00:00)'), + reason: z.string().describe('Reason for visit'), + }), + execute: async ({ patientName, phoneNumber, department, doctorName, scheduledAt, reason }) => { + this.logger.log(`[TOOL] book_appointment: ${patientName} | ${phoneNumber} | ${department} | ${doctorName} | ${scheduledAt}`); + try { + const result = await platformService.queryWithAuth( + `mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`, + { + data: { + name: `AI Booking — ${patientName} (${department})`, + scheduledAt, + status: 'SCHEDULED', + doctorName, + department, + reasonForVisit: reason, + }, + }, + auth, + ); + const id = result?.createAppointment?.id; + if (id) { + return { booked: true, appointmentId: id, message: `Appointment booked for ${patientName} with ${doctorName} on ${scheduledAt}. Reference: ${id.substring(0, 8)}` }; + } + return { booked: false, message: 'Appointment creation failed.' }; + } catch (err: any) { + this.logger.error(`[TOOL] book_appointment failed: ${err.message}`); + return { booked: false, message: `Failed to book: ${err.message}` }; + } + }, + }), + + create_lead: tool({ + description: 'Create a new lead/enquiry for a caller who is not an existing patient. Collect name, phone, and interest.', + inputSchema: z.object({ + name: z.string().describe('Caller name'), + phoneNumber: z.string().describe('Phone number'), + interest: z.string().describe('What they are enquiring about'), + }), + execute: async ({ name, phoneNumber, interest }) => { + this.logger.log(`[TOOL] create_lead: ${name} | ${phoneNumber} | ${interest}`); + try { + const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10); + const result = await platformService.queryWithAuth( + `mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`, + { + data: { + name: `AI Enquiry — ${name}`, + contactName: { + firstName: name.split(' ')[0], + lastName: name.split(' ').slice(1).join(' ') || '', + }, + contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` }, + source: 'PHONE', + status: 'NEW', + interestedService: interest, + }, + }, + auth, + ); + const id = result?.createLead?.id; + if (id) { + return { created: true, leadId: id, message: `Lead created for ${name}. Our team will follow up on ${phoneNumber}.` }; + } + return { created: false, message: 'Lead creation failed.' }; + } catch (err: any) { + this.logger.error(`[TOOL] create_lead failed: ${err.message}`); + return { created: false, message: `Failed: ${err.message}` }; + } + }, + }), + + lookup_call_history: tool({ + description: 'Get call log for a lead — all inbound/outbound calls with dispositions and durations.', + inputSchema: z.object({ + leadId: z.string().describe('Lead ID'), + }), + execute: async ({ leadId }) => { + const data = await platformService.queryWithAuth( + `{ calls(first: 20, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { + id direction callStatus agentName startedAt durationSec disposition + } } } }`, + undefined, auth, + ); + return { calls: data.calls.edges.map((e: any) => e.node) }; + }, + }), + }; + + const result = streamText({ + model: this.aiModel, + system: systemPrompt, + messages, + stopWhen: stepCountIs(5), + tools: isSupervisor ? supervisorTools : agentTools, + }); + + const response = result.toTextStreamResponse(); + res.status(response.status); + response.headers.forEach((value, key) => res.setHeader(key, value)); + if (response.body) { + const reader = response.body.getReader(); + const pump = async () => { + while (true) { + const { done, value } = await reader.read(); + if (done) { res.end(); break; } + res.write(value); + } + }; + pump().catch(() => res.end()); + } else { + res.end(); + } } - this.logger.log('Building knowledge base from platform data...'); - const sections: string[] = []; + private async buildKnowledgeBase(auth: string): Promise { + const now = Date.now(); + if (this.knowledgeBase && now - this.kbLoadedAt < this.kbTtlMs) { + this.logger.log(`KB cache hit (${this.knowledgeBase.length} chars, age ${Math.round((now - this.kbLoadedAt) / 1000)}s)`); + return this.knowledgeBase; + } - try { - const clinicData = await this.platform.queryWithAuth( - `{ clinics(first: 20) { edges { node { + this.logger.log('Building knowledge base from platform data...'); + const sections: string[] = []; + + try { + const clinicData = await this.platform.queryWithAuth( + `{ clinics(first: 20) { edges { node { id name clinicName addressCustom { addressStreet1 addressCity addressState addressPostcode } weekdayHours saturdayHours sundayHours @@ -101,175 +508,253 @@ export class AiChatController { cancellationWindowHours arriveEarlyMin requiredDocuments acceptsCash acceptsCard acceptsUpi } } } }`, - undefined, - auth, - ); - const clinics = clinicData.clinics.edges.map((e: any) => e.node); - if (clinics.length) { - sections.push('## Clinics'); - for (const c of clinics) { - const addr = c.addressCustom - ? [c.addressCustom.addressStreet1, c.addressCustom.addressCity] - .filter(Boolean) - .join(', ') - : ''; - const hours = [ - c.weekdayHours ? `Mon–Fri ${c.weekdayHours}` : '', - c.saturdayHours ? `Sat ${c.saturdayHours}` : '', - c.sundayHours ? `Sun ${c.sundayHours}` : 'Sun closed', - ] - .filter(Boolean) - .join(', '); - sections.push(`- ${c.clinicName ?? c.name}: ${addr}. ${hours}.`); + undefined, auth, + ); + const clinics = clinicData.clinics.edges.map((e: any) => e.node); + if (clinics.length) { + sections.push('## CLINICS & TIMINGS'); + for (const c of clinics) { + const name = c.clinicName ?? c.name; + const addr = c.addressCustom + ? [c.addressCustom.addressStreet1, c.addressCustom.addressCity].filter(Boolean).join(', ') + : ''; + sections.push(`### ${name}`); + if (addr) sections.push(` Address: ${addr}`); + if (c.weekdayHours) sections.push(` Mon–Fri: ${c.weekdayHours}`); + if (c.saturdayHours) sections.push(` Saturday: ${c.saturdayHours}`); + sections.push(` Sunday: ${c.sundayHours ?? 'Closed'}`); + if (c.walkInAllowed) sections.push(` Walk-ins: Accepted`); + } + + const rulesClinic = clinics[0]; + const rules: string[] = []; + if (rulesClinic.cancellationWindowHours) rules.push(`Free cancellation up to ${rulesClinic.cancellationWindowHours}h before`); + if (rulesClinic.arriveEarlyMin) rules.push(`Arrive ${rulesClinic.arriveEarlyMin}min early`); + if (rulesClinic.requiredDocuments) rules.push(`First-time patients bring ${rulesClinic.requiredDocuments}`); + if (rulesClinic.walkInAllowed) rules.push('Walk-ins accepted'); + if (rulesClinic.onlineBooking) rules.push('Online booking available'); + if (rules.length) { + sections.push('\n### Booking Rules'); + sections.push(rules.map(r => `- ${r}`).join('\n')); + } + + const payments: string[] = []; + if (rulesClinic.acceptsCash === 'YES') payments.push('Cash'); + if (rulesClinic.acceptsCard === 'YES') payments.push('Cards'); + if (rulesClinic.acceptsUpi === 'YES') payments.push('UPI'); + if (payments.length) { + sections.push('\n### Payments'); + sections.push(`Accepted: ${payments.join(', ')}.`); + } + } + } catch (err) { + this.logger.warn(`Failed to fetch clinics: ${err}`); + sections.push('## CLINICS\nFailed to load clinic data.'); } - const rulesClinic = clinics[0]; - const rules: string[] = []; - if (rulesClinic.cancellationWindowHours) - rules.push( - `Free cancellation up to ${rulesClinic.cancellationWindowHours}h before`, - ); - if (rulesClinic.arriveEarlyMin) - rules.push(`Arrive ${rulesClinic.arriveEarlyMin}min early`); - if (rulesClinic.requiredDocuments) - rules.push( - `First-time patients bring ${rulesClinic.requiredDocuments}`, - ); - if (rulesClinic.walkInAllowed) rules.push('Walk-ins accepted'); - if (rulesClinic.onlineBooking) rules.push('Online booking available'); - if (rules.length) { - sections.push('\n### Booking Rules'); - sections.push(rules.map((r) => `- ${r}`).join('\n')); + // Add doctors to KB + try { + const docData = await this.platform.queryWithAuth( + `{ doctors(first: 20) { edges { node { + fullName { firstName lastName } department specialty visitingHours + consultationFeeNew { amountMicros currencyCode } + clinic { clinicName } + } } } }`, + undefined, auth, + ); + const doctors = docData.doctors.edges.map((e: any) => e.node); + if (doctors.length) { + sections.push('\n## DOCTORS'); + for (const d of doctors) { + const name = `Dr. ${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''}`.trim(); + const fee = d.consultationFeeNew ? `₹${d.consultationFeeNew.amountMicros / 1_000_000}` : ''; + const clinic = d.clinic?.clinicName ?? ''; + sections.push(`### ${name}`); + sections.push(` Department: ${d.department ?? 'N/A'}`); + sections.push(` Specialty: ${d.specialty ?? 'N/A'}`); + if (d.visitingHours) sections.push(` Hours: ${d.visitingHours}`); + if (fee) sections.push(` Consultation fee: ${fee}`); + if (clinic) sections.push(` Clinic: ${clinic}`); + } + } + } catch (err) { + this.logger.warn(`Failed to fetch doctors for KB: ${err}`); } - const payments: string[] = []; - if (rulesClinic.acceptsCash === 'YES') payments.push('Cash'); - if (rulesClinic.acceptsCard === 'YES') payments.push('Cards'); - if (rulesClinic.acceptsUpi === 'YES') payments.push('UPI'); - if (payments.length) { - sections.push('\n### Payments'); - sections.push(`Accepted: ${payments.join(', ')}.`); - } - } - } catch (err) { - this.logger.warn(`Failed to fetch clinics: ${err}`); - sections.push('## Clinics\nFailed to load clinic data.'); - } - - try { - const pkgData = await this.platform.queryWithAuth( - `{ healthPackages(first: 30, filter: { active: { eq: true } }) { edges { node { + try { + const pkgData = await this.platform.queryWithAuth( + `{ healthPackages(first: 30, filter: { active: { eq: true } }) { edges { node { id name packageName description price { amountMicros currencyCode } discountedPrice { amountMicros currencyCode } department inclusions durationMin eligibility packageTests { edges { node { labTest { testName category } order } } } } } } }`, - undefined, - auth, - ); - const packages = pkgData.healthPackages.edges.map((e: any) => e.node); - if (packages.length) { - sections.push('\n## Health Packages'); - for (const p of packages) { - const price = p.price ? `₹${p.price.amountMicros / 1_000_000}` : ''; - const disc = p.discountedPrice?.amountMicros - ? ` (discounted: ₹${p.discountedPrice.amountMicros / 1_000_000})` - : ''; - const dept = p.department ? ` [${p.department}]` : ''; - sections.push(`- ${p.packageName ?? p.name}: ${price}${disc}${dept}`); - const tests = p.packageTests?.edges - ?.map((e: any) => e.node) - ?.sort((a: any, b: any) => (a.order ?? 0) - (b.order ?? 0)) - ?.map((t: any) => t.labTest?.testName) - ?.filter(Boolean); - if (tests?.length) { - sections.push(` Tests: ${tests.join(', ')}`); - } else if (p.inclusions) { - sections.push(` Includes: ${p.inclusions}`); - } + undefined, auth, + ); + const packages = pkgData.healthPackages.edges.map((e: any) => e.node); + if (packages.length) { + sections.push('\n## Health Packages'); + for (const p of packages) { + const price = p.price ? `₹${p.price.amountMicros / 1_000_000}` : ''; + const disc = p.discountedPrice?.amountMicros ? ` (discounted: ₹${p.discountedPrice.amountMicros / 1_000_000})` : ''; + const dept = p.department ? ` [${p.department}]` : ''; + sections.push(`- ${p.packageName ?? p.name}: ${price}${disc}${dept}`); + const tests = p.packageTests?.edges + ?.map((e: any) => e.node) + ?.sort((a: any, b: any) => (a.order ?? 0) - (b.order ?? 0)) + ?.map((t: any) => t.labTest?.testName) + ?.filter(Boolean); + if (tests?.length) { + sections.push(` Tests: ${tests.join(', ')}`); + } else if (p.inclusions) { + sections.push(` Includes: ${p.inclusions}`); + } + } + } + } catch (err) { + this.logger.warn(`Failed to fetch health packages: ${err}`); + sections.push('\n## Health Packages\nFailed to load package data.'); } - } - } catch (err) { - this.logger.warn(`Failed to fetch health packages: ${err}`); - sections.push('\n## Health Packages\nFailed to load package data.'); - } - try { - const insData = await this.platform.queryWithAuth( - `{ insurancePartners(first: 30, filter: { empanelmentStatus: { eq: ACTIVE } }) { edges { node { + try { + const insData = await this.platform.queryWithAuth( + `{ insurancePartners(first: 30, filter: { empanelmentStatus: { eq: ACTIVE } }) { edges { node { id name insurerName tpaName settlementType planTypesAccepted } } } }`, - undefined, - auth, - ); - const insurers = insData.insurancePartners.edges.map((e: any) => e.node); - if (insurers.length) { - sections.push('\n## Insurance Partners'); - const names = insurers.map((i: any) => { - const settlement = i.settlementType - ? ` (${i.settlementType.toLowerCase()})` - : ''; - return `${i.insurerName ?? i.name}${settlement}`; - }); - sections.push(names.join(', ')); - } - } catch (err) { - this.logger.warn(`Failed to fetch insurance partners: ${err}`); - sections.push('\n## Insurance Partners\nFailed to load insurance data.'); + undefined, auth, + ); + const insurers = insData.insurancePartners.edges.map((e: any) => e.node); + if (insurers.length) { + sections.push('\n## Insurance Partners'); + const names = insurers.map((i: any) => { + const settlement = i.settlementType ? ` (${i.settlementType.toLowerCase()})` : ''; + return `${i.insurerName ?? i.name}${settlement}`; + }); + sections.push(names.join(', ')); + } + } catch (err) { + this.logger.warn(`Failed to fetch insurance partners: ${err}`); + sections.push('\n## Insurance Partners\nFailed to load insurance data.'); + } + + this.knowledgeBase = sections.join('\n') || 'No hospital information available yet.'; + this.kbLoadedAt = now; + this.logger.log(`Knowledge base built (${this.knowledgeBase.length} chars)`); + return this.knowledgeBase; } - this.knowledgeBase = - sections.join('\n') || 'No hospital information available yet.'; - this.kbLoadedAt = now; - this.logger.log( - `Knowledge base built (${this.knowledgeBase.length} chars)`, - ); - return this.knowledgeBase; - } + private buildSupervisorSystemPrompt(): string { + return `You are an AI assistant for supervisors at Global Hospital's call center (Helix Engage). +You help supervisors monitor team performance, identify issues, and make data-driven decisions. - private buildSystemPrompt(kb: string): string { - return `You are an AI assistant for call center agents at a hospital. -You help agents answer questions about patients, doctors, appointments, and hospital services during live calls. +## YOUR CAPABILITIES +You have access to tools that query real-time data: +- **Agent performance**: call counts, conversion rates, NPS scores, idle time, pending follow-ups +- **Campaign stats**: lead counts, conversion rates per campaign, platform breakdown +- **Call summary**: total calls, inbound/outbound split, missed call rate, disposition breakdown +- **SLA breaches**: missed calls that haven't been called back within the SLA threshold + +## RULES +1. ALWAYS use tools to fetch data before answering. NEVER guess or fabricate performance numbers. +2. Be specific — include actual numbers from the tool response, not vague qualifiers. +3. When comparing agents, use their configured thresholds (minConversionPercent, minNpsThreshold, maxIdleMinutes) and team averages. Let the data determine who is underperforming — do not assume. +4. Be concise — supervisors want quick answers. Use bullet points. +5. When recommending actions, ground them in the data returned by tools. +6. If asked about trends, use the call summary tool with different periods. +7. Do not use any agent name in a negative context unless the data explicitly supports it.`; + } + + private buildRulesSystemPrompt(currentConfig: any): string { + const configJson = JSON.stringify(currentConfig, null, 2); + return `You are an AI assistant helping a hospital supervisor configure the Rules Engine for their call center worklist. + +## YOUR ROLE +You help the supervisor understand and optimize priority scoring rules. You explain concepts clearly and make specific recommendations based on their current configuration. + +## SCORING FORMULA +finalScore = baseWeight × slaMultiplier × campaignMultiplier + +- **Base Weight** (0-10): Each task type (missed calls, follow-ups, campaign leads, 2nd/3rd attempts) has a configurable weight. Higher weight = higher priority in the worklist. +- **SLA Multiplier**: Time-based urgency curve. Formula: elapsed^1.6 (accelerates as SLA deadline approaches). Past SLA breach: 1.0 + (excess × 0.5). This means items get increasingly urgent as they approach their SLA deadline. +- **Campaign Multiplier**: (campaignWeight/10) × (sourceWeight/10). IVF(9) × WhatsApp(9) = 0.81. Health(7) × Instagram(5) = 0.35. +- **SLA Thresholds**: Each task type has an SLA in minutes. Missed calls default to 12h (720min), follow-ups to 1d (1440min), campaign leads to 2d (2880min). + +## SLA STATUS COLORS +- Green (low): < 50% SLA elapsed +- Amber (medium): 50-80% SLA elapsed +- Red (high): 80-100% SLA elapsed +- Dark red pulsing (critical): > 100% SLA elapsed (breached) + +## PRIORITY RULES vs AUTOMATION RULES +- **Priority Rules** (what the supervisor is configuring now): Determine worklist order. Computed in real-time at request time. No permanent data changes. +- **Automation Rules** (coming soon): Trigger durable actions — assign leads to agents, escalate SLA breaches to supervisors, update lead status automatically. These write back to entity fields and need a draft/publish workflow. + +## BEST PRACTICES FOR HOSPITAL CALL CENTERS +- Missed calls should have the highest weight (8-10) — these are patients who tried to reach you +- Follow-ups should be high (7-9) — you committed to calling them back +- Campaign leads vary by campaign value (5-8) +- SLA for missed calls: 4-12 hours (shorter = more responsive) +- SLA for follow-ups: 12-24 hours +- High-value campaigns (IVF, cancer screening): weight 8-9 +- General campaigns (health checkup): weight 5-7 +- WhatsApp/Phone leads convert better than social media → weight them higher + +## CURRENT CONFIGURATION +${configJson} + +## RULES +1. Be concise — under 100 words unless asked for detail +2. When recommending changes, be specific: "Set missed call weight to 9, SLA to 8 hours" +3. Explain WHY a change matters: "This ensures IVF patients get called within 4 hours" +4. Reference the scoring formula when explaining scores +5. If asked about automation rules, explain the concept and say it's coming soon`; + } + + private buildSystemPrompt(kb: string): string { + return `You are an AI assistant for call center agents at Global Hospital, Bangalore. +You help agents answer questions about patients, doctors, appointments, clinics, and hospital services during live calls. + +IMPORTANT — ANSWER FROM KNOWLEDGE BASE FIRST: +The knowledge base below contains REAL clinic locations, timings, doctor details, health packages, and insurance partners. +When asked about clinic timings, locations, doctor availability, packages, or insurance — ALWAYS check the knowledge base FIRST before saying you don't know. +Example: "What are the Koramangala timings?" → Look for "Koramangala" in the Clinics section below. RULES: -1. For patient-specific questions, you MUST use lookup tools. NEVER guess patient data. -2. For doctor schedule/fees, use the lookup_doctor tool to get real data from the system. -3. For hospital info (clinics, packages, insurance), use the knowledge base below. -4. If a tool returns no data, say "I couldn't find that in our system." +1. For patient-specific questions (history, appointments, calls), use the lookup tools. NEVER guess patient data. +2. For doctor details beyond what's in the KB, use the lookup_doctor tool. +3. For clinic info, timings, packages, insurance → answer directly from the knowledge base below. +4. If you truly cannot find the answer in the KB or via tools, say "I couldn't find that in our system." 5. Be concise — agents are on live calls. Under 100 words unless asked for detail. 6. NEVER give medical advice, diagnosis, or treatment recommendations. -7. NEVER share sensitive hospital data (revenue, salaries, internal policies). -8. Format with bullet points for easy scanning. +7. Format with bullet points for easy scanning. +KNOWLEDGE BASE (this is real data from our system): ${kb}`; - } + } - private async chatWithTools(userMessage: string, auth: string) { - const kb = await this.buildKnowledgeBase(auth); - this.logger.log(`KB content preview: ${kb.substring(0, 300)}...`); - const systemPrompt = this.buildSystemPrompt(kb); - this.logger.log( - `System prompt length: ${systemPrompt.length} chars, user message: "${userMessage.substring(0, 100)}"`, - ); - const platformService = this.platform; + private async chatWithTools(userMessage: string, auth: string) { + const kb = await this.buildKnowledgeBase(auth); + this.logger.log(`KB content preview: ${kb.substring(0, 300)}...`); + const systemPrompt = this.buildSystemPrompt(kb); + this.logger.log(`System prompt length: ${systemPrompt.length} chars, user message: "${userMessage.substring(0, 100)}"`); + const platformService = this.platform; - const { text, steps } = await generateText({ - model: this.aiModel!, - system: systemPrompt, - prompt: userMessage, - stopWhen: stepCountIs(5), - tools: { - lookup_patient: tool({ - description: - 'Search for a patient/lead by phone number or name. Returns their profile, lead status, AI summary, and linked patient/campaign IDs.', - inputSchema: z.object({ - phone: z.string().optional().describe('Phone number to search'), - name: z.string().optional().describe('Patient/lead name to search'), - }), - execute: async ({ phone, name }) => { - const data = await platformService.queryWithAuth( - `{ leads(first: 50) { edges { node { + const { text, steps } = await generateText({ + model: this.aiModel!, + system: systemPrompt, + prompt: userMessage, + stopWhen: stepCountIs(5), + tools: { + lookup_patient: tool({ + description: 'Search for a patient/lead by phone number or name. Returns their profile, lead status, AI summary, and linked patient/campaign IDs.', + inputSchema: z.object({ + phone: z.string().optional().describe('Phone number to search'), + name: z.string().optional().describe('Patient/lead name to search'), + }), + execute: async ({ phone, name }) => { + const data = await platformService.queryWithAuth( + `{ leads(first: 50) { edges { node { id name contactName { firstName lastName } contactPhone { primaryPhoneNumber } contactEmail { primaryEmail } @@ -277,106 +762,86 @@ ${kb}`; leadScore contactAttempts firstContacted lastContacted aiSummary aiSuggestedAction patientId campaignId } } } }`, - undefined, - auth, - ); - const leads = data.leads.edges.map((e: any) => e.node); - const phoneClean = (phone ?? '').replace(/\D/g, ''); - const nameClean = (name ?? '').toLowerCase(); + undefined, auth, + ); + const leads = data.leads.edges.map((e: any) => e.node); + const phoneClean = (phone ?? '').replace(/\D/g, ''); + const nameClean = (name ?? '').toLowerCase(); - const matched = leads.filter((l: any) => { - if (phoneClean) { - const lp = (l.contactPhone?.primaryPhoneNumber ?? '').replace( - /\D/g, - '', - ); - if (lp.endsWith(phoneClean) || phoneClean.endsWith(lp)) - return true; - } - if (nameClean) { - const fn = - `${l.contactName?.firstName ?? ''} ${l.contactName?.lastName ?? ''}`.toLowerCase(); - if (fn.includes(nameClean)) return true; - } - return false; - }); + const matched = leads.filter((l: any) => { + if (phoneClean) { + const lp = (l.contactPhone?.primaryPhoneNumber ?? '').replace(/\D/g, ''); + if (lp.endsWith(phoneClean) || phoneClean.endsWith(lp)) return true; + } + if (nameClean) { + const fn = `${l.contactName?.firstName ?? ''} ${l.contactName?.lastName ?? ''}`.toLowerCase(); + if (fn.includes(nameClean)) return true; + } + return false; + }); - if (!matched.length) - return { found: false, message: 'No patient/lead found.' }; - return { found: true, count: matched.length, leads: matched }; - }, - }), + if (!matched.length) return { found: false, message: 'No patient/lead found.' }; + return { found: true, count: matched.length, leads: matched }; + }, + }), - lookup_appointments: tool({ - description: - 'Get all appointments (past and upcoming) for a patient. Returns doctor, department, date, status, and reason.', - inputSchema: z.object({ - patientId: z.string().describe('Patient ID'), - }), - execute: async ({ patientId }) => { - const data = await platformService.queryWithAuth( - `{ appointments(first: 20, filter: { patientId: { eq: "${patientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node { + lookup_appointments: tool({ + description: 'Get all appointments (past and upcoming) for a patient. Returns doctor, department, date, status, and reason.', + inputSchema: z.object({ + patientId: z.string().describe('Patient ID'), + }), + execute: async ({ patientId }) => { + const data = await platformService.queryWithAuth( + `{ appointments(first: 20, filter: { patientId: { eq: "${patientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node { id name scheduledAt durationMin appointmentType status doctorName department reasonForVisit doctorId } } } }`, - undefined, - auth, - ); - return { - appointments: data.appointments.edges.map((e: any) => e.node), - }; - }, - }), + undefined, auth, + ); + return { appointments: data.appointments.edges.map((e: any) => e.node) }; + }, + }), - lookup_call_history: tool({ - description: - 'Get call log for a lead — all inbound/outbound calls with dispositions and durations.', - inputSchema: z.object({ - leadId: z.string().describe('Lead ID'), - }), - execute: async ({ leadId }) => { - const data = await platformService.queryWithAuth( - `{ calls(first: 20, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { + lookup_call_history: tool({ + description: 'Get call log for a lead — all inbound/outbound calls with dispositions and durations.', + inputSchema: z.object({ + leadId: z.string().describe('Lead ID'), + }), + execute: async ({ leadId }) => { + const data = await platformService.queryWithAuth( + `{ calls(first: 20, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { id name direction callStatus agentName startedAt durationSec disposition } } } }`, - undefined, - auth, - ); - return { calls: data.calls.edges.map((e: any) => e.node) }; - }, - }), + undefined, auth, + ); + return { calls: data.calls.edges.map((e: any) => e.node) }; + }, + }), - lookup_lead_activities: tool({ - description: - 'Get the full interaction timeline — status changes, calls, WhatsApp, notes, appointments.', - inputSchema: z.object({ - leadId: z.string().describe('Lead ID'), - }), - execute: async ({ leadId }) => { - const data = await platformService.queryWithAuth( - `{ leadActivities(first: 30, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ occurredAt: DescNullsLast }]) { edges { node { + lookup_lead_activities: tool({ + description: 'Get the full interaction timeline — status changes, calls, WhatsApp, notes, appointments.', + inputSchema: z.object({ + leadId: z.string().describe('Lead ID'), + }), + execute: async ({ leadId }) => { + const data = await platformService.queryWithAuth( + `{ leadActivities(first: 30, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ occurredAt: DescNullsLast }]) { edges { node { id activityType summary occurredAt performedBy channel } } } }`, - undefined, - auth, - ); - return { - activities: data.leadActivities.edges.map((e: any) => e.node), - }; - }, - }), + undefined, auth, + ); + return { activities: data.leadActivities.edges.map((e: any) => e.node) }; + }, + }), - lookup_doctor: tool({ - description: - 'Get doctor details — schedule, clinic, fees, qualifications, specialty. Search by name.', - inputSchema: z.object({ - doctorName: z - .string() - .describe('Doctor name (e.g. "Patel", "Sharma")'), - }), - execute: async ({ doctorName }) => { - const data = await platformService.queryWithAuth( - `{ doctors(first: 10) { edges { node { + lookup_doctor: tool({ + description: 'Get doctor details — schedule, clinic, fees, qualifications, specialty. Search by name.', + inputSchema: z.object({ + doctorName: z.string().describe('Doctor name (e.g. "Patel", "Sharma")'), + }), + execute: async ({ doctorName }) => { + const data = await platformService.queryWithAuth( + `{ doctors(first: 10) { edges { node { id name fullName { firstName lastName } department specialty qualifications yearsOfExperience visitingHours @@ -385,125 +850,87 @@ ${kb}`; active registrationNumber clinic { id name clinicName } } } } }`, - undefined, - auth, - ); + undefined, auth, + ); - const doctors = data.doctors.edges.map((e: any) => e.node); - const search = doctorName.toLowerCase(); - const matched = doctors.filter((d: any) => { - const full = - `${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''} ${d.name ?? ''}`.toLowerCase(); - return full.includes(search); - }); + const doctors = data.doctors.edges.map((e: any) => e.node); + const search = doctorName.toLowerCase(); + const matched = doctors.filter((d: any) => { + const full = `${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''} ${d.name ?? ''}`.toLowerCase(); + return full.includes(search); + }); - if (!matched.length) - return { - found: false, - message: `No doctor matching "${doctorName}"`, - }; + if (!matched.length) return { found: false, message: `No doctor matching "${doctorName}"` }; - return { - found: true, - doctors: matched.map((d: any) => ({ - ...d, - clinicName: d.clinic?.clinicName ?? d.clinic?.name ?? 'N/A', - feeNewFormatted: d.consultationFeeNew - ? `₹${d.consultationFeeNew.amountMicros / 1_000_000}` - : 'N/A', - feeFollowUpFormatted: d.consultationFeeFollowUp - ? `₹${d.consultationFeeFollowUp.amountMicros / 1_000_000}` - : 'N/A', - })), - }; - }, - }), - }, - }); + return { + found: true, + doctors: matched.map((d: any) => ({ + ...d, + clinicName: d.clinic?.clinicName ?? d.clinic?.name ?? 'N/A', + feeNewFormatted: d.consultationFeeNew ? `₹${d.consultationFeeNew.amountMicros / 1_000_000}` : 'N/A', + feeFollowUpFormatted: d.consultationFeeFollowUp ? `₹${d.consultationFeeFollowUp.amountMicros / 1_000_000}` : 'N/A', + })), + }; + }, + }), + }, + }); - const toolCallCount = steps.filter((s) => s.toolCalls?.length).length; - this.logger.log( - `Response (${text.length} chars, ${toolCallCount} tool steps)`, - ); + const toolCallCount = steps.filter(s => s.toolCalls?.length).length; + this.logger.log(`Response (${text.length} chars, ${toolCallCount} tool steps)`); - return { - reply: text, - sources: - toolCallCount > 0 ? ['platform_db', 'hospital_kb'] : ['hospital_kb'], - confidence: 'high', - }; - } + return { + reply: text, + sources: toolCallCount > 0 ? ['platform_db', 'hospital_kb'] : ['hospital_kb'], + confidence: 'high', + }; + } - private async fallback(msg: string, auth: string): Promise { - try { - const doctors = await this.platform.queryWithAuth( - `{ doctors(first: 10) { edges { node { + private async fallback(msg: string, auth: string): Promise { + try { + const doctors = await this.platform.queryWithAuth( + `{ doctors(first: 10) { edges { node { name fullName { firstName lastName } department specialty visitingHours consultationFeeNew { amountMicros currencyCode } clinic { name clinicName } } } } }`, - undefined, - auth, - ); - const docs = doctors.doctors.edges.map((e: any) => e.node); - const l = msg.toLowerCase(); + undefined, auth, + ); + const docs = doctors.doctors.edges.map((e: any) => e.node); + const l = msg.toLowerCase(); - const matchedDoc = docs.find((d: any) => { - const full = - `${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''} ${d.name ?? ''}`.toLowerCase(); - return l - .split(/\s+/) - .some((w: string) => w.length > 2 && full.includes(w)); - }); - if (matchedDoc) { - const fee = matchedDoc.consultationFeeNew - ? `₹${matchedDoc.consultationFeeNew.amountMicros / 1_000_000}` - : ''; - const clinic = matchedDoc.clinic?.clinicName ?? ''; - return `Dr. ${matchedDoc.fullName?.lastName ?? matchedDoc.name} (${matchedDoc.department ?? matchedDoc.specialty}): ${matchedDoc.visitingHours ?? 'hours not set'}${clinic ? ` at ${clinic}` : ''}${fee ? `. Fee: ${fee}` : ''}.`; - } + const matchedDoc = docs.find((d: any) => { + const full = `${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''} ${d.name ?? ''}`.toLowerCase(); + return l.split(/\s+/).some((w: string) => w.length > 2 && full.includes(w)); + }); + if (matchedDoc) { + const fee = matchedDoc.consultationFeeNew ? `₹${matchedDoc.consultationFeeNew.amountMicros / 1_000_000}` : ''; + const clinic = matchedDoc.clinic?.clinicName ?? ''; + return `Dr. ${matchedDoc.fullName?.lastName ?? matchedDoc.name} (${matchedDoc.department ?? matchedDoc.specialty}): ${matchedDoc.visitingHours ?? 'hours not set'}${clinic ? ` at ${clinic}` : ''}${fee ? `. Fee: ${fee}` : ''}.`; + } - if (l.includes('doctor') || l.includes('available')) { - return ( - 'Doctors: ' + - docs - .map( - (d: any) => - `${d.fullName?.lastName ?? d.name} (${d.department ?? d.specialty})`, - ) - .join(', ') + - '.' - ); - } + if (l.includes('doctor') || l.includes('available')) { + return 'Doctors: ' + docs.map((d: any) => + `${d.fullName?.lastName ?? d.name} (${d.department ?? d.specialty})` + ).join(', ') + '.'; + } - if ( - l.includes('package') || - l.includes('checkup') || - l.includes('screening') - ) { - const pkgs = await this.platform.queryWithAuth( - `{ healthPackages(first: 20) { edges { node { packageName price { amountMicros } } } } }`, - undefined, - auth, - ); - const packages = pkgs.healthPackages.edges.map((e: any) => e.node); - if (packages.length) { - return ( - 'Packages: ' + - packages - .map( - (p: any) => - `${p.packageName} ₹${p.price?.amountMicros ? p.price.amountMicros / 1_000_000 : 'N/A'}`, - ) - .join(' | ') + - '.' - ); + if (l.includes('package') || l.includes('checkup') || l.includes('screening')) { + const pkgs = await this.platform.queryWithAuth( + `{ healthPackages(first: 20) { edges { node { packageName price { amountMicros } } } } }`, + undefined, auth, + ); + const packages = pkgs.healthPackages.edges.map((e: any) => e.node); + if (packages.length) { + return 'Packages: ' + packages.map((p: any) => + `${p.packageName} ₹${p.price?.amountMicros ? p.price.amountMicros / 1_000_000 : 'N/A'}` + ).join(' | ') + '.'; + } + } + } catch { + // platform unreachable } - } - } catch { - // platform unreachable - } - return 'I can help with: doctor schedules, patient lookup, appointments, packages, insurance. What do you need?'; - } + return 'I can help with: doctor schedules, patient lookup, appointments, packages, insurance. What do you need?'; + } } diff --git a/src/app.module.ts b/src/app.module.ts index a86a90b..369cf8c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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 {} diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 374e22b..6ba6486 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -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('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('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' }; + } } - } } diff --git a/src/auth/session.service.ts b/src/auth/session.service.ts index 9bef931..5d72bf2 100644 --- a/src/auth/session.service.ts +++ b/src/auth/session.service.ts @@ -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('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 { - 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('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 { - 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 { - await this.redis.expire(this.key(agentId), SESSION_TTL); - } + async lockSession(agentId: string, memberId: string, ip?: string): Promise { + 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 { - 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 { + const session = await this.getSession(agentId); + return session ? session.memberId : null; + } + + async refreshSession(agentId: string): Promise { + await this.redis.expire(this.key(agentId), SESSION_TTL); + } + + async unlockSession(agentId: string): Promise { + await this.redis.del(this.key(agentId)); + } + + // Generic cache operations for any module + async getCache(key: string): Promise { + return this.redis.get(key); + } + + async setCache(key: string, value: string, ttlSeconds: number): Promise { + await this.redis.set(key, value, 'EX', ttlSeconds); + } + + async deleteCache(key: string): Promise { + await this.redis.del(key); + } + + async scanKeys(pattern: string): Promise { + 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; + } } diff --git a/src/call-events/call-events.gateway.ts b/src/call-events/call-events.gateway.ts index 3d658d0..5e8138e 100644 --- a/src/call-events/call-events.gateway.ts +++ b/src/call-events/call-events.gateway.ts @@ -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}`); + } } diff --git a/src/call-events/call-events.service.ts b/src/call-events/call-events.service.ts index ec6cc77..b5263d0 100644 --- a/src/call-events/call-events.service.ts +++ b/src/call-events/call-events.service.ts @@ -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 = { - 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 { - 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 { 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 { - 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 { - 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 { + 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 { + 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}`, + ); + } + } } - } } diff --git a/src/caller/caller-resolution.controller.ts b/src/caller/caller-resolution.controller.ts new file mode 100644 index 0000000..35f2487 --- /dev/null +++ b/src/caller/caller-resolution.controller.ts @@ -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' }; + } +} diff --git a/src/caller/caller-resolution.module.ts b/src/caller/caller-resolution.module.ts new file mode 100644 index 0000000..c167d64 --- /dev/null +++ b/src/caller/caller-resolution.module.ts @@ -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 {} diff --git a/src/caller/caller-resolution.service.ts b/src/caller/caller-resolution.service.ts new file mode 100644 index 0000000..719786b --- /dev/null +++ b/src/caller/caller-resolution.service.ts @@ -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 { + 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 { + 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 { + await this.platform.queryWithAuth( + `mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`, + { id: leadId, data: { patientId } }, + auth, + ); + } +} diff --git a/src/config/config-theme.module.ts b/src/config/config-theme.module.ts new file mode 100644 index 0000000..e299fa9 --- /dev/null +++ b/src/config/config-theme.module.ts @@ -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 {} diff --git a/src/config/theme.controller.ts b/src/config/theme.controller.ts new file mode 100644 index 0000000..5298d6c --- /dev/null +++ b/src/config/theme.controller.ts @@ -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) { + 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(); + } +} diff --git a/src/config/theme.defaults.ts b/src/config/theme.defaults.ts new file mode 100644 index 0000000..97c998c --- /dev/null +++ b/src/config/theme.defaults.ts @@ -0,0 +1,79 @@ +export type ThemeConfig = { + version?: number; + updatedAt?: string; + brand: { + name: string; + hospitalName: string; + logo: string; + favicon: string; + }; + colors: { + brand: Record; + }; + 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?' }, + ], + }, +}; diff --git a/src/config/theme.service.ts b/src/config/theme.service.ts new file mode 100644 index 0000000..17febf7 --- /dev/null +++ b/src/config/theme.service.ts @@ -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 { + 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}`); + } + } +} diff --git a/src/events/consumers/ai-insight.consumer.ts b/src/events/consumers/ai-insight.consumer.ts new file mode 100644 index 0000000..63cddff --- /dev/null +++ b/src/events/consumers/ai-insight.consumer.ts @@ -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 { + 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( + `{ 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( + `{ 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( + `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}`); + } + } +} diff --git a/src/events/event-bus.service.ts b/src/events/event-bus.service.ts new file mode 100644 index 0000000..3903517 --- /dev/null +++ b/src/events/event-bus.service.ts @@ -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; + +@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(); + 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 { + 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 { + 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}`); + } + } +} diff --git a/src/events/event-types.ts b/src/events/event-types.ts new file mode 100644 index 0000000..ff93fc0 --- /dev/null +++ b/src/events/event-types.ts @@ -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; diff --git a/src/events/events.module.ts b/src/events/events.module.ts new file mode 100644 index 0000000..b612a79 --- /dev/null +++ b/src/events/events.module.ts @@ -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 {} diff --git a/src/livekit-agent/agent.ts b/src/livekit-agent/agent.ts new file mode 100644 index 0000000..d62ce5f --- /dev/null +++ b/src/livekit-agent/agent.ts @@ -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(query: string, variables?: Record): Promise { + 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 = { 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); +} diff --git a/src/maint/maint.controller.ts b/src/maint/maint.controller.ts new file mode 100644 index 0000000..90c68a7 --- /dev/null +++ b/src/maint/maint.controller.ts @@ -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('OZONETEL_AGENT_ID') ?? 'agent3'; + const password = process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$'; + const sipId = this.config.get('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('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( + `{ 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( + `{ 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( + `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( + `{ 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( + `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( + `{ 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( + `{ 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(); + 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( + `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( + `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( + `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( + `{ 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 } }; + } +} diff --git a/src/maint/maint.guard.ts b/src/maint/maint.guard.ts new file mode 100644 index 0000000..194fa79 --- /dev/null +++ b/src/maint/maint.guard.ts @@ -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; + } +} diff --git a/src/maint/maint.module.ts b/src/maint/maint.module.ts new file mode 100644 index 0000000..a30c795 --- /dev/null +++ b/src/maint/maint.module.ts @@ -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 {} diff --git a/src/ozonetel/ozonetel-agent.controller.ts b/src/ozonetel/ozonetel-agent.controller.ts index b7ad7ee..80740d5 100644 --- a/src/ozonetel/ozonetel-agent.controller.ts +++ b/src/ozonetel/ozonetel-agent.controller.ts @@ -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('OZONETEL_AGENT_ID') ?? 'agent3'; - this.defaultAgentPassword = - config.get('OZONETEL_AGENT_PASSWORD') ?? ''; - this.defaultSipId = config.get('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('OZONETEL_AGENT_ID') ?? 'agent3'; + this.defaultAgentPassword = config.get('OZONETEL_AGENT_PASSWORD') ?? ''; + this.defaultSipId = config.get('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 = { - 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( - `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 = { + 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( + `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 = {}; - 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 = { - 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 = {}; + 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 = { + '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'; + } } diff --git a/src/platform/platform.types.ts b/src/platform/platform.types.ts index 6f585f5..2d2544a 100644 --- a/src/platform/platform.types.ts +++ b/src/platform/platform.types.ts @@ -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; }; diff --git a/src/recordings/recordings.controller.ts b/src/recordings/recordings.controller.ts new file mode 100644 index 0000000..a604892 --- /dev/null +++ b/src/recordings/recordings.controller.ts @@ -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); + } + } +} diff --git a/src/recordings/recordings.module.ts b/src/recordings/recordings.module.ts new file mode 100644 index 0000000..2170128 --- /dev/null +++ b/src/recordings/recordings.module.ts @@ -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 {} diff --git a/src/recordings/recordings.service.ts b/src/recordings/recordings.service.ts new file mode 100644 index 0000000..297ad8b --- /dev/null +++ b/src/recordings/recordings.service.ts @@ -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 { + 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> { + 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 { + 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', + }; + } + } +} diff --git a/src/rules-engine/actions/assign.action.ts b/src/rules-engine/actions/assign.action.ts new file mode 100644 index 0000000..79d1b02 --- /dev/null +++ b/src/rules-engine/actions/assign.action.ts @@ -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): Promise { + return { success: true, data: { stub: true, action: 'assign' } }; + } +} diff --git a/src/rules-engine/actions/escalate.action.ts b/src/rules-engine/actions/escalate.action.ts new file mode 100644 index 0000000..f562172 --- /dev/null +++ b/src/rules-engine/actions/escalate.action.ts @@ -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): Promise { + return { success: true, data: { stub: true, action: 'escalate' } }; + } +} diff --git a/src/rules-engine/actions/score.action.ts b/src/rules-engine/actions/score.action.ts new file mode 100644 index 0000000..cc1b553 --- /dev/null +++ b/src/rules-engine/actions/score.action.ts @@ -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): Promise { + 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 }, + }; + } +} diff --git a/src/rules-engine/consumers/worklist.consumer.ts b/src/rules-engine/consumers/worklist.consumer.ts new file mode 100644 index 0000000..29bc660 --- /dev/null +++ b/src/rules-engine/consumers/worklist.consumer.ts @@ -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 { + 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); + } +} diff --git a/src/rules-engine/facts/agent-facts.provider.ts b/src/rules-engine/facts/agent-facts.provider.ts new file mode 100644 index 0000000..72b0d37 --- /dev/null +++ b/src/rules-engine/facts/agent-facts.provider.ts @@ -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> { + 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, + }; + } +} diff --git a/src/rules-engine/facts/call-facts.provider.ts b/src/rules-engine/facts/call-facts.provider.ts new file mode 100644 index 0000000..3248740 --- /dev/null +++ b/src/rules-engine/facts/call-facts.provider.ts @@ -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> { + 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'; +} diff --git a/src/rules-engine/facts/lead-facts.provider.ts b/src/rules-engine/facts/lead-facts.provider.ts new file mode 100644 index 0000000..58d05d5 --- /dev/null +++ b/src/rules-engine/facts/lead-facts.provider.ts @@ -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> { + 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, + }; + } +} diff --git a/src/rules-engine/rules-engine.controller.ts b/src/rules-engine/rules-engine.controller.ts new file mode 100644 index 0000000..a437ce8 --- /dev/null +++ b/src/rules-engine/rules-engine.controller.ts @@ -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) { + 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 }) { + 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 }; + } +} diff --git a/src/rules-engine/rules-engine.module.ts b/src/rules-engine/rules-engine.module.ts new file mode 100644 index 0000000..9228289 --- /dev/null +++ b/src/rules-engine/rules-engine.module.ts @@ -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 {} diff --git a/src/rules-engine/rules-engine.service.ts b/src/rules-engine/rules-engine.service.ts new file mode 100644 index 0000000..6b3df32 --- /dev/null +++ b/src/rules-engine/rules-engine.service.ts @@ -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; + + 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): 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(); + + 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 { + 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 = { + ...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; + } +} diff --git a/src/rules-engine/rules-storage.service.ts b/src/rules-engine/rules-storage.service.ts new file mode 100644 index 0000000..4dae457 --- /dev/null +++ b/src/rules-engine/rules-storage.service.ts @@ -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('REDIS_URL') ?? 'redis://localhost:6379'); + this.backupDir = config.get('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 { + const data = await this.redis.get(PRIORITY_CONFIG_KEY); + return data ? JSON.parse(data) : DEFAULT_PRIORITY_CONFIG; + } + + async updatePriorityConfig(config: PriorityConfig): Promise { + 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 { + const data = await this.redis.get(RULES_KEY); + return data ? JSON.parse(data) : []; + } + + async getById(id: string): Promise { + const rules = await this.getAll(); + return rules.find(r => r.id === id) ?? null; + } + + async getByTrigger(triggerType: string, triggerValue?: string): Promise { + 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 & { createdBy?: string }): Promise { + 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): Promise { + 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 { + 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 { + const rule = await this.getById(id); + if (!rule) return null; + return this.update(id, { enabled: !rule.enabled }); + } + + async reorder(ids: string[]): Promise { + 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 { + 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'; + } + } +} diff --git a/src/rules-engine/templates/hospital-starter.json b/src/rules-engine/templates/hospital-starter.json new file mode 100644 index 0000000..f7b15f6 --- /dev/null +++ b/src/rules-engine/templates/hospital-starter.json @@ -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" } } + } + ] +} diff --git a/src/rules-engine/types/action.types.ts b/src/rules-engine/types/action.types.ts new file mode 100644 index 0000000..9a8f5ea --- /dev/null +++ b/src/rules-engine/types/action.types.ts @@ -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): Promise; +} + +export type ActionResult = { + success: boolean; + data?: Record; + error?: string; +}; diff --git a/src/rules-engine/types/fact.types.ts b/src/rules-engine/types/fact.types.ts new file mode 100644 index 0000000..dd1e371 --- /dev/null +++ b/src/rules-engine/types/fact.types.ts @@ -0,0 +1,15 @@ +// src/rules-engine/types/fact.types.ts + +export type FactValue = string | number | boolean | string[] | null; + +export type FactContext = { + lead?: Record; + call?: Record; + agent?: Record; + campaign?: Record; +}; + +export interface FactProvider { + name: string; + resolveFacts(entityData: any): Promise>; +} diff --git a/src/rules-engine/types/rule.types.ts b/src/rules-engine/types/rule.types.ts new file mode 100644 index 0000000..9ec7275 --- /dev/null +++ b/src/rules-engine/types/rule.types.ts @@ -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; + campaignWeights: Record; // campaignId → 0-10 + sourceWeights: Record; // 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, + }, +}; diff --git a/src/supervisor/supervisor.controller.ts b/src/supervisor/supervisor.controller.ts index ff1a959..81ddce2 100644 --- a/src/supervisor/supervisor.controller.ts +++ b/src/supervisor/supervisor.controller.ts @@ -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 { + 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)), + ); + } } diff --git a/src/supervisor/supervisor.module.ts b/src/supervisor/supervisor.module.ts index bdb2134..d3f4bbf 100644 --- a/src/supervisor/supervisor.module.ts +++ b/src/supervisor/supervisor.module.ts @@ -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 {} diff --git a/src/supervisor/supervisor.service.ts b/src/supervisor/supervisor.service.ts index 53b91f0..1234380 100644 --- a/src/supervisor/supervisor.service.ts +++ b/src/supervisor/supervisor.service.ts @@ -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(); + private readonly logger = new Logger(SupervisorService.name); + private readonly activeCalls = new Map(); + private readonly agentStates = new Map(); + 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 { - // Get all agents from platform - const agentData = await this.platform.query( - `{ 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 { + // Get all agents from platform + const agentData = await this.platform.query( + `{ 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 }; + } } diff --git a/src/worklist/missed-call-webhook.controller.ts b/src/worklist/missed-call-webhook.controller.ts index e3b8860..386e79f 100644 --- a/src/worklist/missed-call-webhook.controller.ts +++ b/src/worklist/missed-call-webhook.controller.ts @@ -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('platform.apiKey') ?? ''; - } - - @Post('missed-call') - async handleCallWebhook(@Body() body: Record) { - // Ozonetel sends the payload as a JSON string inside a "data" field - let payload: Record; - 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('platform.apiKey') ?? ''; } - this.logger.log( - `Call webhook: ${payload.CallerID} | ${payload.Status} | ${payload.Type}`, - ); + @Post('missed-call') + async handleCallWebhook(@Body() body: Record) { + // Ozonetel sends the payload as a JSON string inside a "data" field + let payload: Record; + 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 { + const callData: Record = { + 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( + `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 { - const callData: Record = { - 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( - `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( - `{ 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( + `{ 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, - authHeader: string, - ): Promise { - await this.platform.queryWithAuth( - `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 { - await this.platform.queryWithAuth( - `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, authHeader: string): Promise { + await this.platform.queryWithAuth( + `mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`, + { id: callId, data }, + authHeader, + ); + } - private async updateLead( - leadId: string, - data: Record, - authHeader: string, - ): Promise { - await this.platform.queryWithAuth( - `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 { + await this.platform.queryWithAuth( + `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, authHeader: string): Promise { + await this.platform.queryWithAuth( + `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 = { - '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 = { + '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; + } } diff --git a/src/worklist/missed-queue.service.ts b/src/worklist/missed-queue.service.ts index b97003c..b2d613b 100644 --- a/src/worklist/missed-queue.service.ts +++ b/src/worklist/missed-queue.service.ts @@ -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(); - private assignmentMutex = false; + private readonly logger = new Logger(MissedQueueService.name); + private readonly pollIntervalMs: number; + private readonly processedUcids = new Set(); + private assignmentMutex = false; - constructor( - private readonly config: ConfigService, - private readonly platform: PlatformGraphqlService, - private readonly ozonetel: OzonetelAgentService, - ) { - this.pollIntervalMs = this.config.get( - '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('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( - `{ 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( + `{ 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( + `{ 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( - `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( - `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( + `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( + `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 { + 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 { - if (this.assignmentMutex) return null; - this.assignmentMutex = true; - - try { - // Find oldest unassigned PENDING_CALLBACK call (empty agentName) - let result = await this.platform.query( - `{ calls(first: 1, filter: { + try { + // Find oldest unassigned PENDING_CALLBACK call (empty agentName) + let result = await this.platform.query( + `{ 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( - `{ calls(first: 1, filter: { + // Also check for null agentName + if (!call) { + result = await this.platform.query( + `{ 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( + `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 { + 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( + `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( - `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 { - 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( - `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( - buildQuery('PENDING_CALLBACK'), - undefined, - authHeader, - ), - this.platform.queryWithAuth( - buildQuery('CALLBACK_ATTEMPTED'), - undefined, - authHeader, - ), - this.platform.queryWithAuth( - buildQuery('CALLBACK_COMPLETED'), - undefined, - authHeader, - ), - this.platform.queryWithAuth( - buildQuery('INVALID'), - undefined, - authHeader, - ), - this.platform.queryWithAuth( - buildQuery('WRONG_NUMBER'), - undefined, - authHeader, - ), - ]); + try { + const [pending, attempted, completed, invalid, wrongNumber] = await Promise.all([ + this.platform.queryWithAuth(buildQuery('PENDING_CALLBACK'), undefined, authHeader), + this.platform.queryWithAuth(buildQuery('CALLBACK_ATTEMPTED'), undefined, authHeader), + this.platform.queryWithAuth(buildQuery('CALLBACK_COMPLETED'), undefined, authHeader), + this.platform.queryWithAuth(buildQuery('INVALID'), undefined, authHeader), + this.platform.queryWithAuth(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: [] }; + } } - } } diff --git a/src/worklist/worklist.controller.ts b/src/worklist/worklist.controller.ts index c9e3aa9..8fb771c 100644 --- a/src/worklist/worklist.controller.ts +++ b/src/worklist/worklist.controller.ts @@ -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 { - try { - const data = await this.platform.queryWithAuth( - `{ 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 { + // 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( + `{ 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); - } } diff --git a/src/worklist/worklist.module.ts b/src/worklist/worklist.module.ts index 155d5ca..0b492c1 100644 --- a/src/worklist/worklist.module.ts +++ b/src/worklist/worklist.module.ts @@ -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 {} diff --git a/src/worklist/worklist.service.ts b/src/worklist/worklist.service.ts index 04857aa..00ededc 100644 --- a/src/worklist/worklist.service.ts +++ b/src/worklist/worklist.service.ts @@ -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 { - 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 { + 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 { - try { - const data = await this.platform.queryWithAuth( - `{ 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 { + try { + const data = await this.platform.queryWithAuth( + `{ 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 { - try { - const data = await this.platform.queryWithAuth( - `{ followUps(first: 20, filter: { assignedAgent: { eq: "${agentName}" } }) { edges { node { + private async getPendingFollowUps(agentName: string, authHeader: string): Promise { + try { + const data = await this.platform.queryWithAuth( + `{ 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 { - try { - // FIFO ordering (AscNullsLast) — oldest first. No agentName filter — missed calls are a shared queue. - const data = await this.platform.queryWithAuth( - `{ 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 { + try { + // FIFO ordering (AscNullsLast) — oldest first. No agentName filter — missed calls are a shared queue. + const data = await this.platform.queryWithAuth( + `{ 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 []; + } } - } }