mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
lint and format
This commit is contained in:
32
.claudeignore
Normal file
32
.claudeignore
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Lock files
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
|
||||||
|
# Test coverage output
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Generated type declarations
|
||||||
|
**/*.d.ts
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# E2E test fixtures (keep unit tests)
|
||||||
|
test/
|
||||||
|
|
||||||
|
# Environment secrets — never read
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
17
.env.example
17
.env.example
@@ -1,17 +0,0 @@
|
|||||||
# Server
|
|
||||||
PORT=4100
|
|
||||||
CORS_ORIGIN=http://localhost:5173
|
|
||||||
|
|
||||||
# Fortytwo Platform
|
|
||||||
PLATFORM_GRAPHQL_URL=http://localhost:4000/graphql
|
|
||||||
PLATFORM_API_KEY=
|
|
||||||
|
|
||||||
# Exotel
|
|
||||||
EXOTEL_API_KEY=
|
|
||||||
EXOTEL_API_TOKEN=
|
|
||||||
EXOTEL_ACCOUNT_SID=
|
|
||||||
EXOTEL_SUBDOMAIN=api.exotel.com
|
|
||||||
EXOTEL_WEBHOOK_SECRET=
|
|
||||||
|
|
||||||
# AI
|
|
||||||
ANTHROPIC_API_KEY=
|
|
||||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
|||||||
|
npx lint-staged
|
||||||
@@ -29,6 +29,16 @@ export default tseslint.config(
|
|||||||
'@typescript-eslint/no-explicit-any': 'off',
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
'@typescript-eslint/no-floating-promises': 'warn',
|
'@typescript-eslint/no-floating-promises': 'warn',
|
||||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||||
|
'@typescript-eslint/no-unsafe-assignment': 'warn',
|
||||||
|
'@typescript-eslint/no-unsafe-call': 'warn',
|
||||||
|
'@typescript-eslint/no-unsafe-member-access': 'warn',
|
||||||
|
'@typescript-eslint/no-unsafe-return': 'warn',
|
||||||
|
'@typescript-eslint/no-unused-vars': 'warn',
|
||||||
|
'@typescript-eslint/no-base-to-string': 'warn',
|
||||||
|
'@typescript-eslint/no-misused-promises': 'warn',
|
||||||
|
'@typescript-eslint/require-await': 'warn',
|
||||||
|
'@typescript-eslint/no-redundant-type-constituents': 'warn',
|
||||||
|
'no-empty': 'warn',
|
||||||
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
654
package-lock.json
generated
654
package-lock.json
generated
@@ -17,6 +17,7 @@
|
|||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/platform-socket.io": "^11.1.17",
|
"@nestjs/platform-socket.io": "^11.1.17",
|
||||||
|
"@nestjs/swagger": "^11.2.6",
|
||||||
"@nestjs/websockets": "^11.1.17",
|
"@nestjs/websockets": "^11.1.17",
|
||||||
"ai": "^6.0.116",
|
"ai": "^6.0.116",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
@@ -38,7 +39,9 @@
|
|||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
"eslint-plugin-prettier": "^5.2.2",
|
"eslint-plugin-prettier": "^5.2.2",
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
|
"husky": "^9.1.7",
|
||||||
"jest": "^30.0.0",
|
"jest": "^30.0.0",
|
||||||
|
"lint-staged": "^16.4.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
@@ -2583,6 +2586,12 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "0.2.12",
|
"version": "0.2.12",
|
||||||
"resolved": "http://localhost:4873/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
"resolved": "http://localhost:4873/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||||
@@ -2899,6 +2908,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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": {
|
"node_modules/@nestjs/platform-express": {
|
||||||
"version": "11.1.17",
|
"version": "11.1.17",
|
||||||
"resolved": "http://localhost:4873/@nestjs/platform-express/-/platform-express-11.1.17.tgz",
|
"resolved": "http://localhost:4873/@nestjs/platform-express/-/platform-express-11.1.17.tgz",
|
||||||
@@ -3037,6 +3066,39 @@
|
|||||||
"tslib": "^2.1.0"
|
"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": {
|
"node_modules/@nestjs/testing": {
|
||||||
"version": "11.1.17",
|
"version": "11.1.17",
|
||||||
"resolved": "http://localhost:4873/@nestjs/testing/-/testing-11.1.17.tgz",
|
"resolved": "http://localhost:4873/@nestjs/testing/-/testing-11.1.17.tgz",
|
||||||
@@ -3160,6 +3222,13 @@
|
|||||||
"url": "https://opencollective.com/pkgr"
|
"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/@sinclair/typebox": {
|
"node_modules/@sinclair/typebox": {
|
||||||
"version": "0.34.48",
|
"version": "0.34.48",
|
||||||
"resolved": "http://localhost:4873/@sinclair/typebox/-/typebox-0.34.48.tgz",
|
"resolved": "http://localhost:4873/@sinclair/typebox/-/typebox-0.34.48.tgz",
|
||||||
@@ -4554,7 +4623,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "http://localhost:4873/argparse/-/argparse-2.0.1.tgz",
|
"resolved": "http://localhost:4873/argparse/-/argparse-2.0.1.tgz",
|
||||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "Python-2.0"
|
"license": "Python-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/array-timsort": {
|
"node_modules/array-timsort": {
|
||||||
@@ -5100,6 +5168,69 @@
|
|||||||
"@colors/colors": "1.5.0"
|
"@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": {
|
"node_modules/cli-width": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "http://localhost:4873/cli-width/-/cli-width-4.1.0.tgz",
|
"resolved": "http://localhost:4873/cli-width/-/cli-width-4.1.0.tgz",
|
||||||
@@ -5191,6 +5322,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/colorette": {
|
||||||
|
"version": "2.0.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
|
||||||
|
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/combined-stream": {
|
"node_modules/combined-stream": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "http://localhost:4873/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "http://localhost:4873/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
@@ -5696,6 +5834,19 @@
|
|||||||
"node": ">=10.13.0"
|
"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": {
|
"node_modules/error-ex": {
|
||||||
"version": "1.3.4",
|
"version": "1.3.4",
|
||||||
"resolved": "http://localhost:4873/error-ex/-/error-ex-1.3.4.tgz",
|
"resolved": "http://localhost:4873/error-ex/-/error-ex-1.3.4.tgz",
|
||||||
@@ -6053,6 +6204,13 @@
|
|||||||
"node": ">= 0.6"
|
"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,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/events": {
|
"node_modules/events": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "http://localhost:4873/events/-/events-3.3.0.tgz",
|
"resolved": "http://localhost:4873/events/-/events-3.3.0.tgz",
|
||||||
@@ -6567,6 +6725,19 @@
|
|||||||
"node": "6.* || 8.* || >= 10.*"
|
"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": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "http://localhost:4873/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "http://localhost:4873/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
@@ -6867,6 +7038,22 @@
|
|||||||
"node": ">=10.17.0"
|
"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": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.7.2",
|
"version": "0.7.2",
|
||||||
"resolved": "http://localhost:4873/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
"resolved": "http://localhost:4873/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||||
@@ -7986,7 +8173,6 @@
|
|||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "http://localhost:4873/js-yaml/-/js-yaml-4.1.1.tgz",
|
"resolved": "http://localhost:4873/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1"
|
"argparse": "^2.0.1"
|
||||||
@@ -8116,6 +8302,156 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"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": {
|
"node_modules/load-esm": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "http://localhost:4873/load-esm/-/load-esm-1.0.3.tgz",
|
"resolved": "http://localhost:4873/load-esm/-/load-esm-1.0.3.tgz",
|
||||||
@@ -8202,6 +8538,209 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "http://localhost:4873/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "http://localhost:4873/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
@@ -8390,6 +8929,19 @@
|
|||||||
"node": ">=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": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.5",
|
"version": "3.1.5",
|
||||||
"resolved": "http://localhost:4873/minimatch/-/minimatch-3.1.5.tgz",
|
"resolved": "http://localhost:4873/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
@@ -9256,6 +9808,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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/router": {
|
"node_modules/router": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "http://localhost:4873/router/-/router-2.2.0.tgz",
|
"resolved": "http://localhost:4873/router/-/router-2.2.0.tgz",
|
||||||
@@ -9508,6 +10067,52 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/socket.io": {
|
||||||
"version": "4.8.3",
|
"version": "4.8.3",
|
||||||
"resolved": "http://localhost:4873/socket.io/-/socket.io-4.8.3.tgz",
|
"resolved": "http://localhost:4873/socket.io/-/socket.io-4.8.3.tgz",
|
||||||
@@ -9679,6 +10284,16 @@
|
|||||||
"safe-buffer": "~5.2.0"
|
"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": {
|
"node_modules/string-length": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "http://localhost:4873/string-length/-/string-length-4.0.2.tgz",
|
"resolved": "http://localhost:4873/string-length/-/string-length-4.0.2.tgz",
|
||||||
@@ -9849,6 +10464,15 @@
|
|||||||
"node": ">=8"
|
"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/symbol-observable": {
|
"node_modules/symbol-observable": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "http://localhost:4873/symbol-observable/-/symbol-observable-4.0.0.tgz",
|
"resolved": "http://localhost:4873/symbol-observable/-/symbol-observable-4.0.0.tgz",
|
||||||
@@ -10092,6 +10716,16 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"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,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "http://localhost:4873/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "http://localhost:4873/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
@@ -11010,6 +11644,22 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/yargs": {
|
||||||
"version": "17.7.2",
|
"version": "17.7.2",
|
||||||
"resolved": "http://localhost:4873/yargs/-/yargs-17.7.2.tgz",
|
"resolved": "http://localhost:4873/yargs/-/yargs-17.7.2.tgz",
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -17,7 +17,8 @@
|
|||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "^3.0.58",
|
"@ai-sdk/anthropic": "^3.0.58",
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/platform-socket.io": "^11.1.17",
|
"@nestjs/platform-socket.io": "^11.1.17",
|
||||||
|
"@nestjs/swagger": "^11.2.6",
|
||||||
"@nestjs/websockets": "^11.1.17",
|
"@nestjs/websockets": "^11.1.17",
|
||||||
"ai": "^6.0.116",
|
"ai": "^6.0.116",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
@@ -49,7 +51,9 @@
|
|||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
"eslint-plugin-prettier": "^5.2.2",
|
"eslint-plugin-prettier": "^5.2.2",
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
|
"husky": "^9.1.7",
|
||||||
"jest": "^30.0.0",
|
"jest": "^30.0.0",
|
||||||
|
"lint-staged": "^16.4.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
@@ -61,6 +65,10 @@
|
|||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"typescript-eslint": "^8.20.0"
|
"typescript-eslint": "^8.20.0"
|
||||||
},
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"src/**/*.ts": ["eslint --fix", "prettier --write"],
|
||||||
|
"test/**/*.ts": ["eslint --fix", "prettier --write"]
|
||||||
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": [
|
"moduleFileExtensions": [
|
||||||
"js",
|
"js",
|
||||||
|
|||||||
6909
pnpm-lock.yaml
generated
Normal file
6909
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,11 @@
|
|||||||
import { Controller, Post, Body, Headers, HttpException, Logger } from '@nestjs/common';
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
Headers,
|
||||||
|
HttpException,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { generateText, tool, stepCountIs } from 'ai';
|
import { generateText, tool, stepCountIs } from 'ai';
|
||||||
import type { LanguageModel } from 'ai';
|
import type { LanguageModel } from 'ai';
|
||||||
@@ -7,72 +14,83 @@ import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
|||||||
import { createAiModel, isAiConfigured } from './ai-provider';
|
import { createAiModel, isAiConfigured } from './ai-provider';
|
||||||
|
|
||||||
type ChatRequest = {
|
type ChatRequest = {
|
||||||
message: string;
|
message: string;
|
||||||
context?: { callerPhone?: string; leadId?: string; leadName?: string };
|
context?: { callerPhone?: string; leadId?: string; leadName?: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
@Controller('api/ai')
|
@Controller('api/ai')
|
||||||
export class AiChatController {
|
export class AiChatController {
|
||||||
private readonly logger = new Logger(AiChatController.name);
|
private readonly logger = new Logger(AiChatController.name);
|
||||||
private readonly aiModel: LanguageModel | null;
|
private readonly aiModel: LanguageModel | null;
|
||||||
private knowledgeBase: string | null = null;
|
private knowledgeBase: string | null = null;
|
||||||
private kbLoadedAt = 0;
|
private kbLoadedAt = 0;
|
||||||
private readonly kbTtlMs = 5 * 60 * 1000;
|
private readonly kbTtlMs = 5 * 60 * 1000;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
private platform: PlatformGraphqlService,
|
private platform: PlatformGraphqlService,
|
||||||
) {
|
) {
|
||||||
this.aiModel = createAiModel(config);
|
this.aiModel = createAiModel(config);
|
||||||
if (!this.aiModel) {
|
if (!this.aiModel) {
|
||||||
this.logger.warn('AI not configured — chat uses fallback');
|
this.logger.warn('AI not configured — chat uses fallback');
|
||||||
} else {
|
} else {
|
||||||
const provider = config.get<string>('ai.provider') ?? 'openai';
|
const provider = config.get<string>('ai.provider') ?? 'openai';
|
||||||
const model = config.get<string>('ai.model') ?? 'gpt-4o-mini';
|
const model = config.get<string>('ai.model') ?? 'gpt-4o-mini';
|
||||||
this.logger.log(`AI configured: ${provider}/${model}`);
|
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`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('chat')
|
if (!this.aiModel) {
|
||||||
async chat(@Body() body: ChatRequest, @Headers('authorization') auth: string) {
|
return {
|
||||||
if (!auth) throw new HttpException('Authorization required', 401);
|
reply: await this.fallback(msg, auth),
|
||||||
if (!body.message?.trim()) throw new HttpException('message required', 400);
|
sources: ['fallback'],
|
||||||
|
confidence: 'low',
|
||||||
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' };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async buildKnowledgeBase(auth: string): Promise<string> {
|
try {
|
||||||
const now = Date.now();
|
return await this.chatWithTools(`${prefix}${msg}`, auth);
|
||||||
if (this.knowledgeBase && now - this.kbLoadedAt < this.kbTtlMs) {
|
} catch (err) {
|
||||||
return this.knowledgeBase;
|
this.logger.error(`AI chat error: ${err}`);
|
||||||
}
|
return {
|
||||||
|
reply: await this.fallback(msg, auth),
|
||||||
|
sources: ['fallback'],
|
||||||
|
confidence: 'low',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.log('Building knowledge base from platform data...');
|
private async buildKnowledgeBase(auth: string): Promise<string> {
|
||||||
const sections: string[] = [];
|
const now = Date.now();
|
||||||
|
if (this.knowledgeBase && now - this.kbLoadedAt < this.kbTtlMs) {
|
||||||
|
return this.knowledgeBase;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
this.logger.log('Building knowledge base from platform data...');
|
||||||
const clinicData = await this.platform.queryWithAuth<any>(
|
const sections: string[] = [];
|
||||||
`{ clinics(first: 20) { edges { node {
|
|
||||||
|
try {
|
||||||
|
const clinicData = await this.platform.queryWithAuth<any>(
|
||||||
|
`{ clinics(first: 20) { edges { node {
|
||||||
id name clinicName
|
id name clinicName
|
||||||
addressCustom { addressStreet1 addressCity addressState addressPostcode }
|
addressCustom { addressStreet1 addressCity addressState addressPostcode }
|
||||||
weekdayHours saturdayHours sundayHours
|
weekdayHours saturdayHours sundayHours
|
||||||
@@ -80,111 +98,132 @@ export class AiChatController {
|
|||||||
cancellationWindowHours arriveEarlyMin requiredDocuments
|
cancellationWindowHours arriveEarlyMin requiredDocuments
|
||||||
acceptsCash acceptsCard acceptsUpi
|
acceptsCash acceptsCard acceptsUpi
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, auth,
|
undefined,
|
||||||
);
|
auth,
|
||||||
const clinics = clinicData.clinics.edges.map((e: any) => e.node);
|
);
|
||||||
if (clinics.length) {
|
const clinics = clinicData.clinics.edges.map((e: any) => e.node);
|
||||||
sections.push('## Clinics');
|
if (clinics.length) {
|
||||||
for (const c of clinics) {
|
sections.push('## Clinics');
|
||||||
const addr = c.addressCustom
|
for (const c of clinics) {
|
||||||
? [c.addressCustom.addressStreet1, c.addressCustom.addressCity].filter(Boolean).join(', ')
|
const addr = c.addressCustom
|
||||||
: '';
|
? [c.addressCustom.addressStreet1, c.addressCustom.addressCity]
|
||||||
const hours = [
|
.filter(Boolean)
|
||||||
c.weekdayHours ? `Mon–Fri ${c.weekdayHours}` : '',
|
.join(', ')
|
||||||
c.saturdayHours ? `Sat ${c.saturdayHours}` : '',
|
: '';
|
||||||
c.sundayHours ? `Sun ${c.sundayHours}` : 'Sun closed',
|
const hours = [
|
||||||
].filter(Boolean).join(', ');
|
c.weekdayHours ? `Mon–Fri ${c.weekdayHours}` : '',
|
||||||
sections.push(`- ${c.clinicName ?? c.name}: ${addr}. ${hours}.`);
|
c.saturdayHours ? `Sat ${c.saturdayHours}` : '',
|
||||||
}
|
c.sundayHours ? `Sun ${c.sundayHours}` : 'Sun closed',
|
||||||
|
]
|
||||||
const rulesClinic = clinics[0];
|
.filter(Boolean)
|
||||||
const rules: string[] = [];
|
.join(', ');
|
||||||
if (rulesClinic.cancellationWindowHours) rules.push(`Free cancellation up to ${rulesClinic.cancellationWindowHours}h before`);
|
sections.push(`- ${c.clinicName ?? c.name}: ${addr}. ${hours}.`);
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const rulesClinic = clinics[0];
|
||||||
const pkgData = await this.platform.queryWithAuth<any>(
|
const rules: string[] = [];
|
||||||
`{ healthPackages(first: 30, filter: { active: { eq: true } }) { edges { node {
|
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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pkgData = await this.platform.queryWithAuth<any>(
|
||||||
|
`{ healthPackages(first: 30, filter: { active: { eq: true } }) { edges { node {
|
||||||
id name packageName description
|
id name packageName description
|
||||||
price { amountMicros currencyCode }
|
price { amountMicros currencyCode }
|
||||||
discountedPrice { amountMicros currencyCode }
|
discountedPrice { amountMicros currencyCode }
|
||||||
department inclusions durationMin eligibility
|
department inclusions durationMin eligibility
|
||||||
packageTests { edges { node { labTest { testName category } order } } }
|
packageTests { edges { node { labTest { testName category } order } } }
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, auth,
|
undefined,
|
||||||
);
|
auth,
|
||||||
const packages = pkgData.healthPackages.edges.map((e: any) => e.node);
|
);
|
||||||
if (packages.length) {
|
const packages = pkgData.healthPackages.edges.map((e: any) => e.node);
|
||||||
sections.push('\n## Health Packages');
|
if (packages.length) {
|
||||||
for (const p of packages) {
|
sections.push('\n## Health Packages');
|
||||||
const price = p.price ? `₹${p.price.amountMicros / 1_000_000}` : '';
|
for (const p of packages) {
|
||||||
const disc = p.discountedPrice?.amountMicros ? ` (discounted: ₹${p.discountedPrice.amountMicros / 1_000_000})` : '';
|
const price = p.price ? `₹${p.price.amountMicros / 1_000_000}` : '';
|
||||||
const dept = p.department ? ` [${p.department}]` : '';
|
const disc = p.discountedPrice?.amountMicros
|
||||||
sections.push(`- ${p.packageName ?? p.name}: ${price}${disc}${dept}`);
|
? ` (discounted: ₹${p.discountedPrice.amountMicros / 1_000_000})`
|
||||||
const tests = p.packageTests?.edges
|
: '';
|
||||||
?.map((e: any) => e.node)
|
const dept = p.department ? ` [${p.department}]` : '';
|
||||||
?.sort((a: any, b: any) => (a.order ?? 0) - (b.order ?? 0))
|
sections.push(`- ${p.packageName ?? p.name}: ${price}${disc}${dept}`);
|
||||||
?.map((t: any) => t.labTest?.testName)
|
const tests = p.packageTests?.edges
|
||||||
?.filter(Boolean);
|
?.map((e: any) => e.node)
|
||||||
if (tests?.length) {
|
?.sort((a: any, b: any) => (a.order ?? 0) - (b.order ?? 0))
|
||||||
sections.push(` Tests: ${tests.join(', ')}`);
|
?.map((t: any) => t.labTest?.testName)
|
||||||
} else if (p.inclusions) {
|
?.filter(Boolean);
|
||||||
sections.push(` Includes: ${p.inclusions}`);
|
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}`);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
try {
|
} catch (err) {
|
||||||
const insData = await this.platform.queryWithAuth<any>(
|
this.logger.warn(`Failed to fetch health packages: ${err}`);
|
||||||
`{ 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}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 buildSystemPrompt(kb: string): string {
|
try {
|
||||||
return `You are an AI assistant for call center agents at a hospital.
|
const insData = await this.platform.queryWithAuth<any>(
|
||||||
|
`{ 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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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.
|
You help agents answer questions about patients, doctors, appointments, and hospital services during live calls.
|
||||||
|
|
||||||
RULES:
|
RULES:
|
||||||
@@ -198,28 +237,29 @@ RULES:
|
|||||||
8. Format with bullet points for easy scanning.
|
8. Format with bullet points for easy scanning.
|
||||||
|
|
||||||
${kb}`;
|
${kb}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async chatWithTools(userMessage: string, auth: string) {
|
private async chatWithTools(userMessage: string, auth: string) {
|
||||||
const kb = await this.buildKnowledgeBase(auth);
|
const kb = await this.buildKnowledgeBase(auth);
|
||||||
const systemPrompt = this.buildSystemPrompt(kb);
|
const systemPrompt = this.buildSystemPrompt(kb);
|
||||||
const platformService = this.platform;
|
const platformService = this.platform;
|
||||||
|
|
||||||
const { text, steps } = await generateText({
|
const { text, steps } = await generateText({
|
||||||
model: this.aiModel!,
|
model: this.aiModel!,
|
||||||
system: systemPrompt,
|
system: systemPrompt,
|
||||||
prompt: userMessage,
|
prompt: userMessage,
|
||||||
stopWhen: stepCountIs(5),
|
stopWhen: stepCountIs(5),
|
||||||
tools: {
|
tools: {
|
||||||
lookup_patient: tool({
|
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.',
|
description:
|
||||||
inputSchema: z.object({
|
'Search for a patient/lead by phone number or name. Returns their profile, lead status, AI summary, and linked patient/campaign IDs.',
|
||||||
phone: z.string().optional().describe('Phone number to search'),
|
inputSchema: z.object({
|
||||||
name: z.string().optional().describe('Patient/lead name to search'),
|
phone: z.string().optional().describe('Phone number to search'),
|
||||||
}),
|
name: z.string().optional().describe('Patient/lead name to search'),
|
||||||
execute: async ({ phone, name }) => {
|
}),
|
||||||
const data = await platformService.queryWithAuth<any>(
|
execute: async ({ phone, name }) => {
|
||||||
`{ leads(first: 50) { edges { node {
|
const data = await platformService.queryWithAuth<any>(
|
||||||
|
`{ leads(first: 50) { edges { node {
|
||||||
id name contactName { firstName lastName }
|
id name contactName { firstName lastName }
|
||||||
contactPhone { primaryPhoneNumber }
|
contactPhone { primaryPhoneNumber }
|
||||||
contactEmail { primaryEmail }
|
contactEmail { primaryEmail }
|
||||||
@@ -227,86 +267,106 @@ ${kb}`;
|
|||||||
leadScore contactAttempts firstContacted lastContacted
|
leadScore contactAttempts firstContacted lastContacted
|
||||||
aiSummary aiSuggestedAction patientId campaignId
|
aiSummary aiSuggestedAction patientId campaignId
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, auth,
|
undefined,
|
||||||
);
|
auth,
|
||||||
const leads = data.leads.edges.map((e: any) => e.node);
|
);
|
||||||
const phoneClean = (phone ?? '').replace(/\D/g, '');
|
const leads = data.leads.edges.map((e: any) => e.node);
|
||||||
const nameClean = (name ?? '').toLowerCase();
|
const phoneClean = (phone ?? '').replace(/\D/g, '');
|
||||||
|
const nameClean = (name ?? '').toLowerCase();
|
||||||
|
|
||||||
const matched = leads.filter((l: any) => {
|
const matched = leads.filter((l: any) => {
|
||||||
if (phoneClean) {
|
if (phoneClean) {
|
||||||
const lp = (l.contactPhone?.primaryPhoneNumber ?? '').replace(/\D/g, '');
|
const lp = (l.contactPhone?.primaryPhoneNumber ?? '').replace(
|
||||||
if (lp.endsWith(phoneClean) || phoneClean.endsWith(lp)) return true;
|
/\D/g,
|
||||||
}
|
'',
|
||||||
if (nameClean) {
|
);
|
||||||
const fn = `${l.contactName?.firstName ?? ''} ${l.contactName?.lastName ?? ''}`.toLowerCase();
|
if (lp.endsWith(phoneClean) || phoneClean.endsWith(lp))
|
||||||
if (fn.includes(nameClean)) return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
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.' };
|
if (!matched.length)
|
||||||
return { found: true, count: matched.length, leads: matched };
|
return { found: false, message: 'No patient/lead found.' };
|
||||||
},
|
return { found: true, count: matched.length, leads: matched };
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
lookup_appointments: tool({
|
lookup_appointments: tool({
|
||||||
description: 'Get all appointments (past and upcoming) for a patient. Returns doctor, department, date, status, and reason.',
|
description:
|
||||||
inputSchema: z.object({
|
'Get all appointments (past and upcoming) for a patient. Returns doctor, department, date, status, and reason.',
|
||||||
patientId: z.string().describe('Patient ID'),
|
inputSchema: z.object({
|
||||||
}),
|
patientId: z.string().describe('Patient ID'),
|
||||||
execute: async ({ patientId }) => {
|
}),
|
||||||
const data = await platformService.queryWithAuth<any>(
|
execute: async ({ patientId }) => {
|
||||||
`{ appointments(first: 20, filter: { patientId: { eq: "${patientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
const data = await platformService.queryWithAuth<any>(
|
||||||
|
`{ appointments(first: 20, filter: { patientId: { eq: "${patientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||||
id name scheduledAt durationMin appointmentType status
|
id name scheduledAt durationMin appointmentType status
|
||||||
doctorName department reasonForVisit doctorId
|
doctorName department reasonForVisit doctorId
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, auth,
|
undefined,
|
||||||
);
|
auth,
|
||||||
return { appointments: data.appointments.edges.map((e: any) => e.node) };
|
);
|
||||||
},
|
return {
|
||||||
}),
|
appointments: data.appointments.edges.map((e: any) => e.node),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
lookup_call_history: tool({
|
lookup_call_history: tool({
|
||||||
description: 'Get call log for a lead — all inbound/outbound calls with dispositions and durations.',
|
description:
|
||||||
inputSchema: z.object({
|
'Get call log for a lead — all inbound/outbound calls with dispositions and durations.',
|
||||||
leadId: z.string().describe('Lead ID'),
|
inputSchema: z.object({
|
||||||
}),
|
leadId: z.string().describe('Lead ID'),
|
||||||
execute: async ({ leadId }) => {
|
}),
|
||||||
const data = await platformService.queryWithAuth<any>(
|
execute: async ({ leadId }) => {
|
||||||
`{ calls(first: 20, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
const data = await platformService.queryWithAuth<any>(
|
||||||
|
`{ calls(first: 20, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
||||||
id name direction callStatus agentName startedAt durationSec disposition
|
id name direction callStatus agentName startedAt durationSec disposition
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, auth,
|
undefined,
|
||||||
);
|
auth,
|
||||||
return { calls: data.calls.edges.map((e: any) => e.node) };
|
);
|
||||||
},
|
return { calls: data.calls.edges.map((e: any) => e.node) };
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
lookup_lead_activities: tool({
|
lookup_lead_activities: tool({
|
||||||
description: 'Get the full interaction timeline — status changes, calls, WhatsApp, notes, appointments.',
|
description:
|
||||||
inputSchema: z.object({
|
'Get the full interaction timeline — status changes, calls, WhatsApp, notes, appointments.',
|
||||||
leadId: z.string().describe('Lead ID'),
|
inputSchema: z.object({
|
||||||
}),
|
leadId: z.string().describe('Lead ID'),
|
||||||
execute: async ({ leadId }) => {
|
}),
|
||||||
const data = await platformService.queryWithAuth<any>(
|
execute: async ({ leadId }) => {
|
||||||
`{ leadActivities(first: 30, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ occurredAt: DescNullsLast }]) { edges { node {
|
const data = await platformService.queryWithAuth<any>(
|
||||||
|
`{ leadActivities(first: 30, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ occurredAt: DescNullsLast }]) { edges { node {
|
||||||
id activityType summary occurredAt performedBy channel
|
id activityType summary occurredAt performedBy channel
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, auth,
|
undefined,
|
||||||
);
|
auth,
|
||||||
return { activities: data.leadActivities.edges.map((e: any) => e.node) };
|
);
|
||||||
},
|
return {
|
||||||
}),
|
activities: data.leadActivities.edges.map((e: any) => e.node),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
lookup_doctor: tool({
|
lookup_doctor: tool({
|
||||||
description: 'Get doctor details — schedule, clinic, fees, qualifications, specialty. Search by name.',
|
description:
|
||||||
inputSchema: z.object({
|
'Get doctor details — schedule, clinic, fees, qualifications, specialty. Search by name.',
|
||||||
doctorName: z.string().describe('Doctor name (e.g. "Patel", "Sharma")'),
|
inputSchema: z.object({
|
||||||
}),
|
doctorName: z
|
||||||
execute: async ({ doctorName }) => {
|
.string()
|
||||||
const data = await platformService.queryWithAuth<any>(
|
.describe('Doctor name (e.g. "Patel", "Sharma")'),
|
||||||
`{ doctors(first: 10) { edges { node {
|
}),
|
||||||
|
execute: async ({ doctorName }) => {
|
||||||
|
const data = await platformService.queryWithAuth<any>(
|
||||||
|
`{ doctors(first: 10) { edges { node {
|
||||||
id name fullName { firstName lastName }
|
id name fullName { firstName lastName }
|
||||||
department specialty qualifications yearsOfExperience
|
department specialty qualifications yearsOfExperience
|
||||||
visitingHours
|
visitingHours
|
||||||
@@ -315,87 +375,125 @@ ${kb}`;
|
|||||||
active registrationNumber
|
active registrationNumber
|
||||||
clinic { id name clinicName }
|
clinic { id name clinicName }
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, auth,
|
undefined,
|
||||||
);
|
auth,
|
||||||
|
);
|
||||||
|
|
||||||
const doctors = data.doctors.edges.map((e: any) => e.node);
|
const doctors = data.doctors.edges.map((e: any) => e.node);
|
||||||
const search = doctorName.toLowerCase();
|
const search = doctorName.toLowerCase();
|
||||||
const matched = doctors.filter((d: any) => {
|
const matched = doctors.filter((d: any) => {
|
||||||
const full = `${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''} ${d.name ?? ''}`.toLowerCase();
|
const full =
|
||||||
return full.includes(search);
|
`${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 {
|
return {
|
||||||
found: true,
|
found: true,
|
||||||
doctors: matched.map((d: any) => ({
|
doctors: matched.map((d: any) => ({
|
||||||
...d,
|
...d,
|
||||||
clinicName: d.clinic?.clinicName ?? d.clinic?.name ?? 'N/A',
|
clinicName: d.clinic?.clinicName ?? d.clinic?.name ?? 'N/A',
|
||||||
feeNewFormatted: d.consultationFeeNew ? `₹${d.consultationFeeNew.amountMicros / 1_000_000}` : 'N/A',
|
feeNewFormatted: d.consultationFeeNew
|
||||||
feeFollowUpFormatted: d.consultationFeeFollowUp ? `₹${d.consultationFeeFollowUp.amountMicros / 1_000_000}` : 'N/A',
|
? `₹${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;
|
const toolCallCount = steps.filter((s) => s.toolCalls?.length).length;
|
||||||
this.logger.log(`Response (${text.length} chars, ${toolCallCount} tool steps)`);
|
this.logger.log(
|
||||||
|
`Response (${text.length} chars, ${toolCallCount} tool steps)`,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
reply: text,
|
reply: text,
|
||||||
sources: toolCallCount > 0 ? ['platform_db', 'hospital_kb'] : ['hospital_kb'],
|
sources:
|
||||||
confidence: 'high',
|
toolCallCount > 0 ? ['platform_db', 'hospital_kb'] : ['hospital_kb'],
|
||||||
};
|
confidence: 'high',
|
||||||
}
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async fallback(msg: string, auth: string): Promise<string> {
|
private async fallback(msg: string, auth: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const doctors = await this.platform.queryWithAuth<any>(
|
const doctors = await this.platform.queryWithAuth<any>(
|
||||||
`{ doctors(first: 10) { edges { node {
|
`{ doctors(first: 10) { edges { node {
|
||||||
name fullName { firstName lastName } department specialty visitingHours
|
name fullName { firstName lastName } department specialty visitingHours
|
||||||
consultationFeeNew { amountMicros currencyCode }
|
consultationFeeNew { amountMicros currencyCode }
|
||||||
clinic { name clinicName }
|
clinic { name clinicName }
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, auth,
|
undefined,
|
||||||
);
|
auth,
|
||||||
const docs = doctors.doctors.edges.map((e: any) => e.node);
|
);
|
||||||
const l = msg.toLowerCase();
|
const docs = doctors.doctors.edges.map((e: any) => e.node);
|
||||||
|
const l = msg.toLowerCase();
|
||||||
|
|
||||||
const matchedDoc = docs.find((d: any) => {
|
const matchedDoc = docs.find((d: any) => {
|
||||||
const full = `${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''} ${d.name ?? ''}`.toLowerCase();
|
const full =
|
||||||
return l.split(/\s+/).some((w: string) => w.length > 2 && full.includes(w));
|
`${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''} ${d.name ?? ''}`.toLowerCase();
|
||||||
});
|
return l
|
||||||
if (matchedDoc) {
|
.split(/\s+/)
|
||||||
const fee = matchedDoc.consultationFeeNew ? `₹${matchedDoc.consultationFeeNew.amountMicros / 1_000_000}` : '';
|
.some((w: string) => w.length > 2 && full.includes(w));
|
||||||
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 (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')) {
|
if (l.includes('doctor') || l.includes('available')) {
|
||||||
return 'Doctors: ' + docs.map((d: any) =>
|
return (
|
||||||
`${d.fullName?.lastName ?? d.name} (${d.department ?? d.specialty})`
|
'Doctors: ' +
|
||||||
).join(', ') + '.';
|
docs
|
||||||
}
|
.map(
|
||||||
|
(d: any) =>
|
||||||
|
`${d.fullName?.lastName ?? d.name} (${d.department ?? d.specialty})`,
|
||||||
|
)
|
||||||
|
.join(', ') +
|
||||||
|
'.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (l.includes('package') || l.includes('checkup') || l.includes('screening')) {
|
if (
|
||||||
const pkgs = await this.platform.queryWithAuth<any>(
|
l.includes('package') ||
|
||||||
`{ healthPackages(first: 20) { edges { node { packageName price { amountMicros } } } } }`,
|
l.includes('checkup') ||
|
||||||
undefined, auth,
|
l.includes('screening')
|
||||||
);
|
) {
|
||||||
const packages = pkgs.healthPackages.edges.map((e: any) => e.node);
|
const pkgs = await this.platform.queryWithAuth<any>(
|
||||||
if (packages.length) {
|
`{ healthPackages(first: 20) { edges { node { packageName price { amountMicros } } } } }`,
|
||||||
return 'Packages: ' + packages.map((p: any) =>
|
undefined,
|
||||||
`${p.packageName} ₹${p.price?.amountMicros ? p.price.amountMicros / 1_000_000 : 'N/A'}`
|
auth,
|
||||||
).join(' | ') + '.';
|
);
|
||||||
}
|
const packages = pkgs.healthPackages.edges.map((e: any) => e.node);
|
||||||
}
|
if (packages.length) {
|
||||||
} catch {
|
return (
|
||||||
// platform unreachable
|
'Packages: ' +
|
||||||
|
packages
|
||||||
|
.map(
|
||||||
|
(p: any) =>
|
||||||
|
`${p.packageName} ₹${p.price?.amountMicros ? p.price.amountMicros / 1_000_000 : 'N/A'}`,
|
||||||
|
)
|
||||||
|
.join(' | ') +
|
||||||
|
'.'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return 'I can help with: doctor schedules, patient lookup, appointments, packages, insurance. What do you need?';
|
} catch {
|
||||||
|
// platform unreachable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return 'I can help with: doctor schedules, patient lookup, appointments, packages, insurance. What do you need?';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,57 +6,66 @@ import { z } from 'zod';
|
|||||||
import { createAiModel } from './ai-provider';
|
import { createAiModel } from './ai-provider';
|
||||||
|
|
||||||
type LeadContext = {
|
type LeadContext = {
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
leadSource?: string;
|
leadSource?: string;
|
||||||
interestedService?: string;
|
interestedService?: string;
|
||||||
leadStatus?: string;
|
leadStatus?: string;
|
||||||
contactAttempts?: number;
|
contactAttempts?: number;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
campaignId?: string;
|
campaignId?: string;
|
||||||
activities?: { activityType: string; summary: string }[];
|
activities?: { activityType: string; summary: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type EnrichmentResult = {
|
type EnrichmentResult = {
|
||||||
aiSummary: string;
|
aiSummary: string;
|
||||||
aiSuggestedAction: string;
|
aiSuggestedAction: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const enrichmentSchema = z.object({
|
const enrichmentSchema = z.object({
|
||||||
aiSummary: z.string().describe('1-2 sentence summary of who this lead is and their history'),
|
aiSummary: z
|
||||||
aiSuggestedAction: z.string().describe('5-10 word suggested action for the agent'),
|
.string()
|
||||||
|
.describe('1-2 sentence summary of who this lead is and their history'),
|
||||||
|
aiSuggestedAction: z
|
||||||
|
.string()
|
||||||
|
.describe('5-10 word suggested action for the agent'),
|
||||||
});
|
});
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AiEnrichmentService {
|
export class AiEnrichmentService {
|
||||||
private readonly logger = new Logger(AiEnrichmentService.name);
|
private readonly logger = new Logger(AiEnrichmentService.name);
|
||||||
private readonly aiModel: LanguageModel | null;
|
private readonly aiModel: LanguageModel | null;
|
||||||
|
|
||||||
constructor(private config: ConfigService) {
|
constructor(private config: ConfigService) {
|
||||||
this.aiModel = createAiModel(config);
|
this.aiModel = createAiModel(config);
|
||||||
if (!this.aiModel) {
|
if (!this.aiModel) {
|
||||||
this.logger.warn('AI not configured — enrichment uses fallback');
|
this.logger.warn('AI not configured — enrichment uses fallback');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async enrichLead(lead: LeadContext): Promise<EnrichmentResult> {
|
||||||
|
if (!this.aiModel) {
|
||||||
|
return this.fallbackEnrichment(lead);
|
||||||
}
|
}
|
||||||
|
|
||||||
async enrichLead(lead: LeadContext): Promise<EnrichmentResult> {
|
try {
|
||||||
if (!this.aiModel) {
|
const daysSince = lead.createdAt
|
||||||
return this.fallbackEnrichment(lead);
|
? Math.floor(
|
||||||
}
|
(Date.now() - new Date(lead.createdAt).getTime()) /
|
||||||
|
(1000 * 60 * 60 * 24),
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
|
||||||
try {
|
const activitiesText = lead.activities?.length
|
||||||
const daysSince = lead.createdAt
|
? lead.activities
|
||||||
? Math.floor((Date.now() - new Date(lead.createdAt).getTime()) / (1000 * 60 * 60 * 24))
|
.map((a) => `- ${a.activityType}: ${a.summary}`)
|
||||||
: 0;
|
.join('\n')
|
||||||
|
: 'No previous interactions';
|
||||||
|
|
||||||
const activitiesText = lead.activities?.length
|
const { object } = await generateObject({
|
||||||
? lead.activities.map(a => `- ${a.activityType}: ${a.summary}`).join('\n')
|
model: this.aiModel,
|
||||||
: 'No previous interactions';
|
schema: enrichmentSchema,
|
||||||
|
prompt: `You are an AI assistant for a hospital call center.
|
||||||
const { object } = await generateObject({
|
|
||||||
model: this.aiModel!,
|
|
||||||
schema: enrichmentSchema,
|
|
||||||
prompt: `You are an AI assistant for a hospital call center.
|
|
||||||
An inbound call is coming in from a lead. Summarize their history and suggest what the call center agent should do.
|
An inbound call is coming in from a lead. Summarize their history and suggest what the call center agent should do.
|
||||||
|
|
||||||
Lead details:
|
Lead details:
|
||||||
@@ -69,39 +78,45 @@ Lead details:
|
|||||||
|
|
||||||
Recent activity:
|
Recent activity:
|
||||||
${activitiesText}`,
|
${activitiesText}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`AI enrichment generated for lead ${lead.firstName} ${lead.lastName}`);
|
this.logger.log(
|
||||||
return object;
|
`AI enrichment generated for lead ${lead.firstName} ${lead.lastName}`,
|
||||||
} catch (error) {
|
);
|
||||||
this.logger.error(`AI enrichment failed: ${error}`);
|
return object;
|
||||||
return this.fallbackEnrichment(lead);
|
} catch (error) {
|
||||||
}
|
this.logger.error(`AI enrichment failed: ${error}`);
|
||||||
|
return this.fallbackEnrichment(lead);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fallbackEnrichment(lead: LeadContext): EnrichmentResult {
|
||||||
|
const daysSince = lead.createdAt
|
||||||
|
? Math.floor(
|
||||||
|
(Date.now() - new Date(lead.createdAt).getTime()) /
|
||||||
|
(1000 * 60 * 60 * 24),
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const attempts = lead.contactAttempts ?? 0;
|
||||||
|
const service = lead.interestedService ?? 'general inquiry';
|
||||||
|
const source =
|
||||||
|
lead.leadSource?.replace(/_/g, ' ').toLowerCase() ?? 'unknown source';
|
||||||
|
|
||||||
|
let summary: string;
|
||||||
|
let action: string;
|
||||||
|
|
||||||
|
if (attempts === 0) {
|
||||||
|
summary = `First-time inquiry from ${source}. Interested in ${service}. Lead is ${daysSince} day(s) old with no previous contact.`;
|
||||||
|
action = `Introduce services and offer appointment booking`;
|
||||||
|
} else if (attempts === 1) {
|
||||||
|
summary = `Returning inquiry — contacted once before. Interested in ${service} via ${source}. Lead is ${daysSince} day(s) old.`;
|
||||||
|
action = `Follow up on previous conversation, offer appointment`;
|
||||||
|
} else {
|
||||||
|
summary = `Repeat contact (${attempts} previous attempts) for ${service}. Originally from ${source}, ${daysSince} day(s) old. Shows high interest.`;
|
||||||
|
action = `Prioritize appointment booking — high-intent lead`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private fallbackEnrichment(lead: LeadContext): EnrichmentResult {
|
return { aiSummary: summary, aiSuggestedAction: action };
|
||||||
const daysSince = lead.createdAt
|
}
|
||||||
? Math.floor((Date.now() - new Date(lead.createdAt).getTime()) / (1000 * 60 * 60 * 24))
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const attempts = lead.contactAttempts ?? 0;
|
|
||||||
const service = lead.interestedService ?? 'general inquiry';
|
|
||||||
const source = lead.leadSource?.replace(/_/g, ' ').toLowerCase() ?? 'unknown source';
|
|
||||||
|
|
||||||
let summary: string;
|
|
||||||
let action: string;
|
|
||||||
|
|
||||||
if (attempts === 0) {
|
|
||||||
summary = `First-time inquiry from ${source}. Interested in ${service}. Lead is ${daysSince} day(s) old with no previous contact.`;
|
|
||||||
action = `Introduce services and offer appointment booking`;
|
|
||||||
} else if (attempts === 1) {
|
|
||||||
summary = `Returning inquiry — contacted once before. Interested in ${service} via ${source}. Lead is ${daysSince} day(s) old.`;
|
|
||||||
action = `Follow up on previous conversation, offer appointment`;
|
|
||||||
} else {
|
|
||||||
summary = `Repeat contact (${attempts} previous attempts) for ${service}. Originally from ${source}, ${daysSince} day(s) old. Shows high interest.`;
|
|
||||||
action = `Prioritize appointment booking — high-intent lead`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { aiSummary: summary, aiSuggestedAction: action };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,23 +4,24 @@ import { openai } from '@ai-sdk/openai';
|
|||||||
import type { LanguageModel } from 'ai';
|
import type { LanguageModel } from 'ai';
|
||||||
|
|
||||||
export function createAiModel(config: ConfigService): LanguageModel | null {
|
export function createAiModel(config: ConfigService): LanguageModel | null {
|
||||||
const provider = config.get<string>('ai.provider') ?? 'openai';
|
const provider = config.get<string>('ai.provider') ?? 'openai';
|
||||||
const model = config.get<string>('ai.model') ?? 'gpt-4o-mini';
|
const model = config.get<string>('ai.model') ?? 'gpt-4o-mini';
|
||||||
|
|
||||||
if (provider === 'anthropic') {
|
if (provider === 'anthropic') {
|
||||||
const apiKey = config.get<string>('ai.anthropicApiKey');
|
const apiKey = config.get<string>('ai.anthropicApiKey');
|
||||||
if (!apiKey) return null;
|
|
||||||
return anthropic(model);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to openai
|
|
||||||
const apiKey = config.get<string>('ai.openaiApiKey');
|
|
||||||
if (!apiKey) return null;
|
if (!apiKey) return null;
|
||||||
return openai(model);
|
return anthropic(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to openai
|
||||||
|
const apiKey = config.get<string>('ai.openaiApiKey');
|
||||||
|
if (!apiKey) return null;
|
||||||
|
return openai(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAiConfigured(config: ConfigService): boolean {
|
export function isAiConfigured(config: ConfigService): boolean {
|
||||||
const provider = config.get<string>('ai.provider') ?? 'openai';
|
const provider = config.get<string>('ai.provider') ?? 'openai';
|
||||||
if (provider === 'anthropic') return !!config.get<string>('ai.anthropicApiKey');
|
if (provider === 'anthropic')
|
||||||
return !!config.get<string>('ai.openaiApiKey');
|
return !!config.get<string>('ai.anthropicApiKey');
|
||||||
|
return !!config.get<string>('ai.openaiApiKey');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { AiEnrichmentService } from './ai-enrichment.service';
|
|||||||
import { AiChatController } from './ai-chat.controller';
|
import { AiChatController } from './ai-chat.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule],
|
imports: [PlatformModule],
|
||||||
controllers: [AiChatController],
|
controllers: [AiChatController],
|
||||||
providers: [AiEnrichmentService],
|
providers: [AiEnrichmentService],
|
||||||
exports: [AiEnrichmentService],
|
exports: [AiEnrichmentService],
|
||||||
})
|
})
|
||||||
export class AiModule {}
|
export class AiModule {}
|
||||||
|
|||||||
@@ -14,22 +14,22 @@ import { CallAssistModule } from './call-assist/call-assist.module';
|
|||||||
import { SearchModule } from './search/search.module';
|
import { SearchModule } from './search/search.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
load: [configuration],
|
load: [configuration],
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
}),
|
}),
|
||||||
AiModule,
|
AiModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
PlatformModule,
|
PlatformModule,
|
||||||
ExotelModule,
|
ExotelModule,
|
||||||
CallEventsModule,
|
CallEventsModule,
|
||||||
OzonetelAgentModule,
|
OzonetelAgentModule,
|
||||||
GraphqlProxyModule,
|
GraphqlProxyModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
WorklistModule,
|
WorklistModule,
|
||||||
CallAssistModule,
|
CallAssistModule,
|
||||||
SearchModule,
|
SearchModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -5,28 +5,32 @@ import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
|||||||
|
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
private readonly logger = new Logger(AuthController.name);
|
private readonly logger = new Logger(AuthController.name);
|
||||||
private readonly graphqlUrl: string;
|
private readonly graphqlUrl: string;
|
||||||
private readonly workspaceSubdomain: string;
|
private readonly workspaceSubdomain: string;
|
||||||
private readonly origin: string;
|
private readonly origin: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
private ozonetelAgent: OzonetelAgentService,
|
private ozonetelAgent: OzonetelAgentService,
|
||||||
) {
|
) {
|
||||||
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
|
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
|
||||||
this.workspaceSubdomain = process.env.PLATFORM_WORKSPACE_SUBDOMAIN ?? 'fortytwo-dev';
|
this.workspaceSubdomain =
|
||||||
this.origin = process.env.PLATFORM_ORIGIN ?? 'http://fortytwo-dev.localhost:4010';
|
process.env.PLATFORM_WORKSPACE_SUBDOMAIN ?? 'fortytwo-dev';
|
||||||
}
|
this.origin =
|
||||||
|
process.env.PLATFORM_ORIGIN ?? 'http://fortytwo-dev.localhost:4010';
|
||||||
|
}
|
||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
async login(@Body() body: { email: string; password: string }) {
|
async login(@Body() body: { email: string; password: string }) {
|
||||||
this.logger.log(`Login attempt for ${body.email}`);
|
this.logger.log(`Login attempt for ${body.email}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Get login token
|
// Step 1: Get login token
|
||||||
const loginRes = await axios.post(this.graphqlUrl, {
|
const loginRes = await axios.post(
|
||||||
query: `mutation GetLoginToken($email: String!, $password: String!) {
|
this.graphqlUrl,
|
||||||
|
{
|
||||||
|
query: `mutation GetLoginToken($email: String!, $password: String!) {
|
||||||
getLoginTokenFromCredentials(
|
getLoginTokenFromCredentials(
|
||||||
email: $email
|
email: $email
|
||||||
password: $password
|
password: $password
|
||||||
@@ -35,26 +39,31 @@ export class AuthController {
|
|||||||
loginToken { token }
|
loginToken { token }
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
variables: { email: body.email, password: body.password },
|
variables: { email: body.email, password: body.password },
|
||||||
}, {
|
},
|
||||||
headers: {
|
{
|
||||||
'Content-Type': 'application/json',
|
headers: {
|
||||||
'X-Workspace-Subdomain': this.workspaceSubdomain,
|
'Content-Type': 'application/json',
|
||||||
},
|
'X-Workspace-Subdomain': this.workspaceSubdomain,
|
||||||
});
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (loginRes.data.errors) {
|
if (loginRes.data.errors) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
loginRes.data.errors[0]?.message ?? 'Login failed',
|
loginRes.data.errors[0]?.message ?? 'Login failed',
|
||||||
401,
|
401,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const loginToken = loginRes.data.data.getLoginTokenFromCredentials.loginToken.token;
|
const loginToken =
|
||||||
|
loginRes.data.data.getLoginTokenFromCredentials.loginToken.token;
|
||||||
|
|
||||||
// Step 2: Exchange for access + refresh tokens
|
// Step 2: Exchange for access + refresh tokens
|
||||||
const tokenRes = await axios.post(this.graphqlUrl, {
|
const tokenRes = await axios.post(
|
||||||
query: `mutation GetAuthTokens($loginToken: String!) {
|
this.graphqlUrl,
|
||||||
|
{
|
||||||
|
query: `mutation GetAuthTokens($loginToken: String!) {
|
||||||
getAuthTokensFromLoginToken(
|
getAuthTokensFromLoginToken(
|
||||||
loginToken: $loginToken
|
loginToken: $loginToken
|
||||||
origin: "${this.origin}"
|
origin: "${this.origin}"
|
||||||
@@ -65,99 +74,114 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
variables: { loginToken },
|
variables: { loginToken },
|
||||||
}, {
|
},
|
||||||
headers: {
|
{
|
||||||
'Content-Type': 'application/json',
|
headers: {
|
||||||
'X-Workspace-Subdomain': this.workspaceSubdomain,
|
'Content-Type': 'application/json',
|
||||||
},
|
'X-Workspace-Subdomain': this.workspaceSubdomain,
|
||||||
});
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (tokenRes.data.errors) {
|
if (tokenRes.data.errors) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
tokenRes.data.errors[0]?.message ?? 'Token exchange failed',
|
tokenRes.data.errors[0]?.message ?? 'Token exchange failed',
|
||||||
401,
|
401,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokens = tokenRes.data.data.getAuthTokensFromLoginToken.tokens;
|
const tokens = tokenRes.data.data.getAuthTokensFromLoginToken.tokens;
|
||||||
const accessToken = tokens.accessOrWorkspaceAgnosticToken.token;
|
const accessToken = tokens.accessOrWorkspaceAgnosticToken.token;
|
||||||
|
|
||||||
// Step 3: Fetch user profile with roles
|
// Step 3: Fetch user profile with roles
|
||||||
const profileRes = await axios.post(this.graphqlUrl, {
|
const profileRes = await axios.post(
|
||||||
query: `{ currentUser { id email workspaceMember { id name { firstName lastName } userEmail avatarUrl roles { id label } } } }`,
|
this.graphqlUrl,
|
||||||
}, {
|
{
|
||||||
headers: {
|
query: `{ currentUser { id email workspaceMember { id name { firstName lastName } userEmail avatarUrl roles { id label } } } }`,
|
||||||
'Content-Type': 'application/json',
|
},
|
||||||
'Authorization': `Bearer ${accessToken}`,
|
{
|
||||||
},
|
headers: {
|
||||||
});
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const currentUser = profileRes.data?.data?.currentUser;
|
const currentUser = profileRes.data?.data?.currentUser;
|
||||||
const workspaceMember = currentUser?.workspaceMember;
|
const workspaceMember = currentUser?.workspaceMember;
|
||||||
const roles = workspaceMember?.roles ?? [];
|
const roles = workspaceMember?.roles ?? [];
|
||||||
const roleLabels = roles.map((r: any) => r.label);
|
const roleLabels = roles.map((r: any) => r.label);
|
||||||
|
|
||||||
// Determine app role from platform roles
|
// Determine app role from platform roles
|
||||||
let appRole = 'executive'; // default
|
let appRole = 'executive'; // default
|
||||||
if (roleLabels.includes('HelixEngage Manager')) {
|
if (roleLabels.includes('HelixEngage Manager')) {
|
||||||
appRole = 'admin';
|
appRole = 'admin';
|
||||||
} else if (roleLabels.includes('HelixEngage User')) {
|
} else if (roleLabels.includes('HelixEngage User')) {
|
||||||
// Distinguish CC agent from executive by email convention or config
|
// Distinguish CC agent from executive by email convention or config
|
||||||
// For now, emails containing 'cc' map to cc-agent
|
// For now, emails containing 'cc' map to cc-agent
|
||||||
const email = workspaceMember?.userEmail ?? body.email;
|
const email = workspaceMember?.userEmail ?? body.email;
|
||||||
appRole = email.includes('cc') ? 'cc-agent' : 'executive';
|
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(', ')})`,
|
||||||
|
);
|
||||||
|
|
||||||
// Auto-login Ozonetel agent for CC agents (fire and forget)
|
// Auto-login Ozonetel agent for CC agents (fire and forget)
|
||||||
if (appRole === 'cc-agent') {
|
if (appRole === 'cc-agent') {
|
||||||
const ozAgentId = process.env.OZONETEL_AGENT_ID ?? 'agent3';
|
const ozAgentId = process.env.OZONETEL_AGENT_ID ?? 'agent3';
|
||||||
const ozAgentPassword = process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$';
|
const ozAgentPassword =
|
||||||
const ozSipId = process.env.OZONETEL_SIP_ID ?? '521814';
|
process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$';
|
||||||
|
const ozSipId = process.env.OZONETEL_SIP_ID ?? '521814';
|
||||||
|
|
||||||
this.ozonetelAgent.loginAgent({
|
this.ozonetelAgent
|
||||||
agentId: ozAgentId,
|
.loginAgent({
|
||||||
password: ozAgentPassword,
|
agentId: ozAgentId,
|
||||||
phoneNumber: ozSipId,
|
password: ozAgentPassword,
|
||||||
mode: 'blended',
|
phoneNumber: ozSipId,
|
||||||
}).catch(err => {
|
mode: 'blended',
|
||||||
this.logger.warn(`Ozonetel agent login failed (non-blocking): ${err.message}`);
|
})
|
||||||
});
|
.catch((err) => {
|
||||||
}
|
this.logger.warn(
|
||||||
|
`Ozonetel agent login failed (non-blocking): ${err.message}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken: tokens.refreshToken.token,
|
refreshToken: tokens.refreshToken.token,
|
||||||
user: {
|
user: {
|
||||||
id: currentUser?.id,
|
id: currentUser?.id,
|
||||||
email: currentUser?.email,
|
email: currentUser?.email,
|
||||||
firstName: workspaceMember?.name?.firstName ?? '',
|
firstName: workspaceMember?.name?.firstName ?? '',
|
||||||
lastName: workspaceMember?.name?.lastName ?? '',
|
lastName: workspaceMember?.name?.lastName ?? '',
|
||||||
avatarUrl: workspaceMember?.avatarUrl,
|
avatarUrl: workspaceMember?.avatarUrl,
|
||||||
role: appRole,
|
role: appRole,
|
||||||
platformRoles: roleLabels,
|
platformRoles: roleLabels,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
this.logger.error(`Login proxy failed: ${error}`);
|
this.logger.error(`Login proxy failed: ${error}`);
|
||||||
throw new HttpException('Authentication service unavailable', 503);
|
throw new HttpException('Authentication service unavailable', 503);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('refresh')
|
||||||
|
async refresh(@Body() body: { refreshToken: string }) {
|
||||||
|
if (!body.refreshToken) {
|
||||||
|
throw new HttpException('refreshToken required', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('refresh')
|
this.logger.log('Token refresh request');
|
||||||
async refresh(@Body() body: { refreshToken: string }) {
|
|
||||||
if (!body.refreshToken) {
|
|
||||||
throw new HttpException('refreshToken required', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log('Token refresh request');
|
try {
|
||||||
|
const res = await axios.post(
|
||||||
try {
|
this.graphqlUrl,
|
||||||
const res = await axios.post(this.graphqlUrl, {
|
{
|
||||||
query: `mutation RefreshToken($token: String!) {
|
query: `mutation RefreshToken($token: String!) {
|
||||||
renewToken(appToken: $token) {
|
renewToken(appToken: $token) {
|
||||||
tokens {
|
tokens {
|
||||||
accessOrWorkspaceAgnosticToken { token expiresAt }
|
accessOrWorkspaceAgnosticToken { token expiresAt }
|
||||||
@@ -165,25 +189,29 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
variables: { token: body.refreshToken },
|
variables: { token: body.refreshToken },
|
||||||
}, {
|
},
|
||||||
headers: { 'Content-Type': 'application/json' },
|
{
|
||||||
});
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (res.data.errors) {
|
if (res.data.errors) {
|
||||||
this.logger.warn(`Token refresh failed: ${res.data.errors[0]?.message}`);
|
this.logger.warn(
|
||||||
throw new HttpException('Token refresh failed', 401);
|
`Token refresh failed: ${res.data.errors[0]?.message}`,
|
||||||
}
|
);
|
||||||
|
throw new HttpException('Token refresh failed', 401);
|
||||||
|
}
|
||||||
|
|
||||||
const tokens = res.data.data.renewToken.tokens;
|
const tokens = res.data.data.renewToken.tokens;
|
||||||
return {
|
return {
|
||||||
accessToken: tokens.accessOrWorkspaceAgnosticToken.token,
|
accessToken: tokens.accessOrWorkspaceAgnosticToken.token,
|
||||||
refreshToken: tokens.refreshToken.token,
|
refreshToken: tokens.refreshToken.token,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
this.logger.error(`Token refresh failed: ${error}`);
|
this.logger.error(`Token refresh failed: ${error}`);
|
||||||
throw new HttpException('Token refresh failed', 401);
|
throw new HttpException('Token refresh failed', 401);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { AuthController } from './auth.controller';
|
|||||||
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [OzonetelAgentModule],
|
imports: [OzonetelAgentModule],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
WebSocketGateway,
|
WebSocketGateway,
|
||||||
SubscribeMessage,
|
SubscribeMessage,
|
||||||
MessageBody,
|
MessageBody,
|
||||||
ConnectedSocket,
|
ConnectedSocket,
|
||||||
OnGatewayDisconnect,
|
OnGatewayDisconnect,
|
||||||
} from '@nestjs/websockets';
|
} from '@nestjs/websockets';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { Socket } from 'socket.io';
|
import { Socket } from 'socket.io';
|
||||||
@@ -11,126 +11,138 @@ import WebSocket from 'ws';
|
|||||||
import { CallAssistService } from './call-assist.service';
|
import { CallAssistService } from './call-assist.service';
|
||||||
|
|
||||||
type SessionState = {
|
type SessionState = {
|
||||||
deepgramWs: WebSocket | null;
|
deepgramWs: WebSocket | null;
|
||||||
transcript: string;
|
transcript: string;
|
||||||
context: string;
|
context: string;
|
||||||
suggestionTimer: NodeJS.Timeout | null;
|
suggestionTimer: NodeJS.Timeout | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@WebSocketGateway({
|
@WebSocketGateway({
|
||||||
cors: { origin: process.env.CORS_ORIGIN ?? '*', credentials: true },
|
cors: { origin: process.env.CORS_ORIGIN ?? '*', credentials: true },
|
||||||
namespace: '/call-assist',
|
namespace: '/call-assist',
|
||||||
})
|
})
|
||||||
export class CallAssistGateway implements OnGatewayDisconnect {
|
export class CallAssistGateway implements OnGatewayDisconnect {
|
||||||
private readonly logger = new Logger(CallAssistGateway.name);
|
private readonly logger = new Logger(CallAssistGateway.name);
|
||||||
private readonly sessions = new Map<string, SessionState>();
|
private readonly sessions = new Map<string, SessionState>();
|
||||||
private readonly deepgramApiKey: string;
|
private readonly deepgramApiKey: string;
|
||||||
|
|
||||||
constructor(private readonly callAssist: CallAssistService) {
|
constructor(private readonly callAssist: CallAssistService) {
|
||||||
this.deepgramApiKey = process.env.DEEPGRAM_API_KEY ?? '';
|
this.deepgramApiKey = process.env.DEEPGRAM_API_KEY ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('call-assist:start')
|
||||||
|
async handleStart(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody()
|
||||||
|
data: { ucid: string; leadId?: string; callerPhone?: string },
|
||||||
|
) {
|
||||||
|
this.logger.log(
|
||||||
|
`Call assist start: ucid=${data.ucid} lead=${data.leadId ?? 'none'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const context = await this.callAssist.loadCallContext(
|
||||||
|
data.leadId ?? null,
|
||||||
|
data.callerPhone ?? null,
|
||||||
|
);
|
||||||
|
client.emit('call-assist:context', {
|
||||||
|
context: context.substring(0, 200) + '...',
|
||||||
|
});
|
||||||
|
|
||||||
|
const session: SessionState = {
|
||||||
|
deepgramWs: null,
|
||||||
|
transcript: '',
|
||||||
|
context,
|
||||||
|
suggestionTimer: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.deepgramApiKey) {
|
||||||
|
const dgUrl = `wss://api.deepgram.com/v1/listen?model=nova-2&language=en&smart_format=true&interim_results=true&endpointing=300&sample_rate=16000&encoding=linear16&channels=1`;
|
||||||
|
|
||||||
|
const dgWs = new WebSocket(dgUrl, {
|
||||||
|
headers: { Authorization: `Token ${this.deepgramApiKey}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
dgWs.on('open', () => {
|
||||||
|
this.logger.log(`Deepgram connected for ${data.ucid}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
dgWs.on('message', (raw: WebSocket.Data) => {
|
||||||
|
try {
|
||||||
|
const result = JSON.parse(raw.toString());
|
||||||
|
const text = result.channel?.alternatives?.[0]?.transcript;
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
const isFinal = result.is_final;
|
||||||
|
client.emit('call-assist:transcript', { text, isFinal });
|
||||||
|
|
||||||
|
if (isFinal) {
|
||||||
|
session.transcript += `Customer: ${text}\n`;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
dgWs.on('error', (err) => {
|
||||||
|
this.logger.error(`Deepgram error: ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
dgWs.on('close', () => {
|
||||||
|
this.logger.log(`Deepgram closed for ${data.ucid}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
session.deepgramWs = dgWs;
|
||||||
|
} else {
|
||||||
|
this.logger.warn('DEEPGRAM_API_KEY not set — transcription disabled');
|
||||||
|
client.emit('call-assist:error', {
|
||||||
|
message: 'Transcription not configured',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@SubscribeMessage('call-assist:start')
|
// AI suggestion every 10 seconds
|
||||||
async handleStart(
|
session.suggestionTimer = setInterval(async () => {
|
||||||
@ConnectedSocket() client: Socket,
|
if (!session.transcript.trim()) return;
|
||||||
@MessageBody() data: { ucid: string; leadId?: string; callerPhone?: string },
|
const suggestion = await this.callAssist.getSuggestion(
|
||||||
) {
|
session.transcript,
|
||||||
this.logger.log(`Call assist start: ucid=${data.ucid} lead=${data.leadId ?? 'none'}`);
|
session.context,
|
||||||
|
);
|
||||||
|
if (suggestion) {
|
||||||
|
client.emit('call-assist:suggestion', { text: suggestion });
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
const context = await this.callAssist.loadCallContext(
|
this.sessions.set(client.id, session);
|
||||||
data.leadId ?? null,
|
}
|
||||||
data.callerPhone ?? null,
|
|
||||||
);
|
|
||||||
client.emit('call-assist:context', { context: context.substring(0, 200) + '...' });
|
|
||||||
|
|
||||||
const session: SessionState = {
|
@SubscribeMessage('call-assist:audio')
|
||||||
deepgramWs: null,
|
handleAudio(
|
||||||
transcript: '',
|
@ConnectedSocket() client: Socket,
|
||||||
context,
|
@MessageBody() audioData: ArrayBuffer,
|
||||||
suggestionTimer: null,
|
) {
|
||||||
};
|
const session = this.sessions.get(client.id);
|
||||||
|
if (session?.deepgramWs?.readyState === WebSocket.OPEN) {
|
||||||
if (this.deepgramApiKey) {
|
session.deepgramWs.send(Buffer.from(audioData));
|
||||||
const dgUrl = `wss://api.deepgram.com/v1/listen?model=nova-2&language=en&smart_format=true&interim_results=true&endpointing=300&sample_rate=16000&encoding=linear16&channels=1`;
|
|
||||||
|
|
||||||
const dgWs = new WebSocket(dgUrl, {
|
|
||||||
headers: { Authorization: `Token ${this.deepgramApiKey}` },
|
|
||||||
});
|
|
||||||
|
|
||||||
dgWs.on('open', () => {
|
|
||||||
this.logger.log(`Deepgram connected for ${data.ucid}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
dgWs.on('message', (raw: WebSocket.Data) => {
|
|
||||||
try {
|
|
||||||
const result = JSON.parse(raw.toString());
|
|
||||||
const text = result.channel?.alternatives?.[0]?.transcript;
|
|
||||||
if (!text) return;
|
|
||||||
|
|
||||||
const isFinal = result.is_final;
|
|
||||||
client.emit('call-assist:transcript', { text, isFinal });
|
|
||||||
|
|
||||||
if (isFinal) {
|
|
||||||
session.transcript += `Customer: ${text}\n`;
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
});
|
|
||||||
|
|
||||||
dgWs.on('error', (err) => {
|
|
||||||
this.logger.error(`Deepgram error: ${err.message}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
dgWs.on('close', () => {
|
|
||||||
this.logger.log(`Deepgram closed for ${data.ucid}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
session.deepgramWs = dgWs;
|
|
||||||
} else {
|
|
||||||
this.logger.warn('DEEPGRAM_API_KEY not set — transcription disabled');
|
|
||||||
client.emit('call-assist:error', { message: 'Transcription not configured' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// AI suggestion every 10 seconds
|
|
||||||
session.suggestionTimer = setInterval(async () => {
|
|
||||||
if (!session.transcript.trim()) return;
|
|
||||||
const suggestion = await this.callAssist.getSuggestion(session.transcript, session.context);
|
|
||||||
if (suggestion) {
|
|
||||||
client.emit('call-assist:suggestion', { text: suggestion });
|
|
||||||
}
|
|
||||||
}, 10000);
|
|
||||||
|
|
||||||
this.sessions.set(client.id, session);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@SubscribeMessage('call-assist:audio')
|
@SubscribeMessage('call-assist:stop')
|
||||||
handleAudio(
|
handleStop(@ConnectedSocket() client: Socket) {
|
||||||
@ConnectedSocket() client: Socket,
|
this.cleanup(client.id);
|
||||||
@MessageBody() audioData: ArrayBuffer,
|
this.logger.log(`Call assist stopped: ${client.id}`);
|
||||||
) {
|
}
|
||||||
const session = this.sessions.get(client.id);
|
|
||||||
if (session?.deepgramWs?.readyState === WebSocket.OPEN) {
|
|
||||||
session.deepgramWs.send(Buffer.from(audioData));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SubscribeMessage('call-assist:stop')
|
handleDisconnect(client: Socket) {
|
||||||
handleStop(@ConnectedSocket() client: Socket) {
|
this.cleanup(client.id);
|
||||||
this.cleanup(client.id);
|
}
|
||||||
this.logger.log(`Call assist stopped: ${client.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleDisconnect(client: Socket) {
|
private cleanup(clientId: string) {
|
||||||
this.cleanup(client.id);
|
const session = this.sessions.get(clientId);
|
||||||
}
|
if (session) {
|
||||||
|
if (session.suggestionTimer) clearInterval(session.suggestionTimer);
|
||||||
private cleanup(clientId: string) {
|
if (session.deepgramWs) {
|
||||||
const session = this.sessions.get(clientId);
|
try {
|
||||||
if (session) {
|
session.deepgramWs.close();
|
||||||
if (session.suggestionTimer) clearInterval(session.suggestionTimer);
|
} catch {}
|
||||||
if (session.deepgramWs) {
|
}
|
||||||
try { session.deepgramWs.close(); } catch {}
|
this.sessions.delete(clientId);
|
||||||
}
|
|
||||||
this.sessions.delete(clientId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { CallAssistService } from './call-assist.service';
|
|||||||
import { PlatformModule } from '../platform/platform.module';
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule],
|
imports: [PlatformModule],
|
||||||
providers: [CallAssistGateway, CallAssistService],
|
providers: [CallAssistGateway, CallAssistService],
|
||||||
})
|
})
|
||||||
export class CallAssistModule {}
|
export class CallAssistModule {}
|
||||||
|
|||||||
@@ -7,99 +7,119 @@ import type { LanguageModel } from 'ai';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CallAssistService {
|
export class CallAssistService {
|
||||||
private readonly logger = new Logger(CallAssistService.name);
|
private readonly logger = new Logger(CallAssistService.name);
|
||||||
private readonly aiModel: LanguageModel | null;
|
private readonly aiModel: LanguageModel | null;
|
||||||
private readonly platformApiKey: string;
|
private readonly platformApiKey: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
private platform: PlatformGraphqlService,
|
private platform: PlatformGraphqlService,
|
||||||
) {
|
) {
|
||||||
this.aiModel = createAiModel(config);
|
this.aiModel = createAiModel(config);
|
||||||
this.platformApiKey = config.get<string>('platform.apiKey') ?? '';
|
this.platformApiKey = config.get<string>('platform.apiKey') ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadCallContext(leadId: string | null, callerPhone: string | null): Promise<string> {
|
async loadCallContext(
|
||||||
const authHeader = this.platformApiKey ? `Bearer ${this.platformApiKey}` : '';
|
leadId: string | null,
|
||||||
if (!authHeader) return 'No platform context available.';
|
callerPhone: string | null,
|
||||||
|
): Promise<string> {
|
||||||
|
const authHeader = this.platformApiKey
|
||||||
|
? `Bearer ${this.platformApiKey}`
|
||||||
|
: '';
|
||||||
|
if (!authHeader) return 'No platform context available.';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
|
||||||
if (leadId) {
|
if (leadId) {
|
||||||
const leadResult = await this.platform.queryWithAuth<any>(
|
const leadResult = await this.platform.queryWithAuth<any>(
|
||||||
`{ leads(filter: { id: { eq: "${leadId}" } }) { edges { node {
|
`{ leads(filter: { id: { eq: "${leadId}" } }) { edges { node {
|
||||||
id name contactName { firstName lastName }
|
id name contactName { firstName lastName }
|
||||||
contactPhone { primaryPhoneNumber }
|
contactPhone { primaryPhoneNumber }
|
||||||
source status interestedService
|
source status interestedService
|
||||||
lastContacted contactAttempts
|
lastContacted contactAttempts
|
||||||
aiSummary aiSuggestedAction
|
aiSummary aiSuggestedAction
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, authHeader,
|
undefined,
|
||||||
);
|
authHeader,
|
||||||
const lead = leadResult.leads.edges[0]?.node;
|
);
|
||||||
if (lead) {
|
const lead = leadResult.leads.edges[0]?.node;
|
||||||
const name = lead.contactName
|
if (lead) {
|
||||||
? `${lead.contactName.firstName} ${lead.contactName.lastName}`.trim()
|
const name = lead.contactName
|
||||||
: lead.name;
|
? `${lead.contactName.firstName} ${lead.contactName.lastName}`.trim()
|
||||||
parts.push(`CALLER: ${name}`);
|
: lead.name;
|
||||||
parts.push(`Phone: ${lead.contactPhone?.primaryPhoneNumber ?? callerPhone}`);
|
parts.push(`CALLER: ${name}`);
|
||||||
parts.push(`Source: ${lead.source ?? 'Unknown'}`);
|
parts.push(
|
||||||
parts.push(`Interested in: ${lead.interestedService ?? 'Not specified'}`);
|
`Phone: ${lead.contactPhone?.primaryPhoneNumber ?? callerPhone}`,
|
||||||
parts.push(`Contact attempts: ${lead.contactAttempts ?? 0}`);
|
);
|
||||||
if (lead.aiSummary) parts.push(`AI Summary: ${lead.aiSummary}`);
|
parts.push(`Source: ${lead.source ?? 'Unknown'}`);
|
||||||
}
|
parts.push(
|
||||||
|
`Interested in: ${lead.interestedService ?? 'Not specified'}`,
|
||||||
|
);
|
||||||
|
parts.push(`Contact attempts: ${lead.contactAttempts ?? 0}`);
|
||||||
|
if (lead.aiSummary) parts.push(`AI Summary: ${lead.aiSummary}`);
|
||||||
|
}
|
||||||
|
|
||||||
const apptResult = await this.platform.queryWithAuth<any>(
|
const apptResult = await this.platform.queryWithAuth<any>(
|
||||||
`{ appointments(first: 10, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
`{ appointments(first: 10, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||||
id scheduledAt appointmentStatus doctorName department reasonForVisit patientId
|
id scheduledAt appointmentStatus doctorName department reasonForVisit patientId
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, authHeader,
|
undefined,
|
||||||
);
|
authHeader,
|
||||||
const appts = apptResult.appointments.edges
|
);
|
||||||
.map((e: any) => e.node)
|
const appts = apptResult.appointments.edges
|
||||||
.filter((a: any) => a.patientId === leadId);
|
.map((e: any) => e.node)
|
||||||
if (appts.length > 0) {
|
.filter((a: any) => a.patientId === leadId);
|
||||||
parts.push('\nPAST APPOINTMENTS:');
|
if (appts.length > 0) {
|
||||||
for (const a of appts) {
|
parts.push('\nPAST APPOINTMENTS:');
|
||||||
const date = a.scheduledAt ? new Date(a.scheduledAt).toLocaleDateString('en-IN') : '?';
|
for (const a of appts) {
|
||||||
parts.push(`- ${date}: ${a.doctorName ?? '?'} (${a.department ?? '?'}) — ${a.appointmentStatus}`);
|
const date = a.scheduledAt
|
||||||
}
|
? new Date(a.scheduledAt).toLocaleDateString('en-IN')
|
||||||
}
|
: '?';
|
||||||
} else if (callerPhone) {
|
parts.push(
|
||||||
parts.push(`CALLER: Unknown (${callerPhone})`);
|
`- ${date}: ${a.doctorName ?? '?'} (${a.department ?? '?'}) — ${a.appointmentStatus}`,
|
||||||
parts.push('No lead record found — this may be a new enquiry.');
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} else if (callerPhone) {
|
||||||
|
parts.push(`CALLER: Unknown (${callerPhone})`);
|
||||||
|
parts.push('No lead record found — this may be a new enquiry.');
|
||||||
|
}
|
||||||
|
|
||||||
const docResult = await this.platform.queryWithAuth<any>(
|
const docResult = await this.platform.queryWithAuth<any>(
|
||||||
`{ doctors(first: 20) { edges { node {
|
`{ doctors(first: 20) { edges { node {
|
||||||
fullName { firstName lastName } department specialty clinic { clinicName }
|
fullName { firstName lastName } department specialty clinic { clinicName }
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, authHeader,
|
undefined,
|
||||||
);
|
authHeader,
|
||||||
const docs = docResult.doctors.edges.map((e: any) => e.node);
|
);
|
||||||
if (docs.length > 0) {
|
const docs = docResult.doctors.edges.map((e: any) => e.node);
|
||||||
parts.push('\nAVAILABLE DOCTORS:');
|
if (docs.length > 0) {
|
||||||
for (const d of docs) {
|
parts.push('\nAVAILABLE DOCTORS:');
|
||||||
const name = d.fullName ? `Dr. ${d.fullName.firstName} ${d.fullName.lastName}`.trim() : 'Unknown';
|
for (const d of docs) {
|
||||||
parts.push(`- ${name} — ${d.department ?? '?'} — ${d.clinic?.clinicName ?? '?'}`);
|
const name = d.fullName
|
||||||
}
|
? `Dr. ${d.fullName.firstName} ${d.fullName.lastName}`.trim()
|
||||||
}
|
: 'Unknown';
|
||||||
|
parts.push(
|
||||||
return parts.join('\n') || 'No context available.';
|
`- ${name} — ${d.department ?? '?'} — ${d.clinic?.clinicName ?? '?'}`,
|
||||||
} catch (err) {
|
);
|
||||||
this.logger.error(`Failed to load call context: ${err}`);
|
|
||||||
return 'Context loading failed.';
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join('\n') || 'No context available.';
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Failed to load call context: ${err}`);
|
||||||
|
return 'Context loading failed.';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getSuggestion(transcript: string, context: string): Promise<string> {
|
async getSuggestion(transcript: string, context: string): Promise<string> {
|
||||||
if (!this.aiModel || !transcript.trim()) return '';
|
if (!this.aiModel || !transcript.trim()) return '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { text } = await generateText({
|
const { text } = await generateText({
|
||||||
model: this.aiModel,
|
model: this.aiModel,
|
||||||
system: `You are a real-time call assistant for Global Hospital Bangalore.
|
system: `You are a real-time call assistant for Global Hospital Bangalore.
|
||||||
You listen to the customer's words and provide brief, actionable suggestions for the CC agent.
|
You listen to the customer's words and provide brief, actionable suggestions for the CC agent.
|
||||||
|
|
||||||
${context}
|
${context}
|
||||||
@@ -111,13 +131,13 @@ RULES:
|
|||||||
- If customer wants to cancel or reschedule, note relevant appointment details
|
- If customer wants to cancel or reschedule, note relevant appointment details
|
||||||
- If customer sounds upset, suggest empathetic response
|
- If customer sounds upset, suggest empathetic response
|
||||||
- Do NOT repeat what the agent already knows`,
|
- Do NOT repeat what the agent already knows`,
|
||||||
prompt: `Conversation transcript so far:\n${transcript}\n\nProvide a brief suggestion for the agent based on what was just said.`,
|
prompt: `Conversation transcript so far:\n${transcript}\n\nProvide a brief suggestion for the agent based on what was just said.`,
|
||||||
maxOutputTokens: 150,
|
maxOutputTokens: 150,
|
||||||
});
|
});
|
||||||
return text;
|
return text;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`AI suggestion failed: ${err}`);
|
this.logger.error(`AI suggestion failed: ${err}`);
|
||||||
return '';
|
return '';
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,76 +1,79 @@
|
|||||||
import {
|
import {
|
||||||
WebSocketGateway,
|
WebSocketGateway,
|
||||||
WebSocketServer,
|
WebSocketServer,
|
||||||
SubscribeMessage,
|
SubscribeMessage,
|
||||||
MessageBody,
|
MessageBody,
|
||||||
ConnectedSocket,
|
ConnectedSocket,
|
||||||
} from '@nestjs/websockets';
|
} from '@nestjs/websockets';
|
||||||
import { Logger, Inject, forwardRef } from '@nestjs/common';
|
import { Logger, Inject, forwardRef } from '@nestjs/common';
|
||||||
import { Server, Socket } from 'socket.io';
|
import { Server, Socket } from 'socket.io';
|
||||||
import type { EnrichedCallEvent, DispositionPayload } from './call-events.types';
|
import type {
|
||||||
|
EnrichedCallEvent,
|
||||||
|
DispositionPayload,
|
||||||
|
} from './call-events.types';
|
||||||
import { CallEventsService } from './call-events.service';
|
import { CallEventsService } from './call-events.service';
|
||||||
|
|
||||||
@WebSocketGateway({
|
@WebSocketGateway({
|
||||||
cors: {
|
cors: {
|
||||||
origin: process.env.CORS_ORIGIN ?? 'http://localhost:5173',
|
origin: process.env.CORS_ORIGIN ?? 'http://localhost:5173',
|
||||||
credentials: true,
|
credentials: true,
|
||||||
},
|
},
|
||||||
namespace: '/call-events',
|
namespace: '/call-events',
|
||||||
})
|
})
|
||||||
export class CallEventsGateway {
|
export class CallEventsGateway {
|
||||||
@WebSocketServer()
|
@WebSocketServer()
|
||||||
server: Server;
|
server: Server;
|
||||||
|
|
||||||
private readonly logger = new Logger(CallEventsGateway.name);
|
private readonly logger = new Logger(CallEventsGateway.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(forwardRef(() => CallEventsService))
|
@Inject(forwardRef(() => CallEventsService))
|
||||||
private readonly callEventsService: CallEventsService,
|
private readonly callEventsService: CallEventsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// Push enriched call event to a specific agent's room
|
// Push enriched call event to a specific agent's room
|
||||||
pushCallEvent(agentName: string, event: EnrichedCallEvent) {
|
pushCallEvent(agentName: string, event: EnrichedCallEvent) {
|
||||||
const room = `agent:${agentName}`;
|
const room = `agent:${agentName}`;
|
||||||
this.logger.log(`Pushing ${event.eventType} event to room ${room}`);
|
this.logger.log(`Pushing ${event.eventType} event to room ${room}`);
|
||||||
this.server.to(room).emit('call:incoming', event);
|
this.server.to(room).emit('call:incoming', event);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agent registers when they open the Call Desk page
|
// Agent registers when they open the Call Desk page
|
||||||
@SubscribeMessage('agent:register')
|
@SubscribeMessage('agent:register')
|
||||||
handleAgentRegister(
|
handleAgentRegister(
|
||||||
@ConnectedSocket() client: Socket,
|
@ConnectedSocket() client: Socket,
|
||||||
@MessageBody() agentName: string,
|
@MessageBody() agentName: string,
|
||||||
) {
|
) {
|
||||||
const room = `agent:${agentName}`;
|
const room = `agent:${agentName}`;
|
||||||
client.join(room);
|
client.join(room);
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Agent ${agentName} registered in room ${room} (socket: ${client.id})`,
|
`Agent ${agentName} registered in room ${room} (socket: ${client.id})`,
|
||||||
);
|
);
|
||||||
client.emit('agent:registered', { agentName, room });
|
client.emit('agent:registered', { agentName, room });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agent sends disposition after a call
|
// Agent sends disposition after a call
|
||||||
@SubscribeMessage('call:disposition')
|
@SubscribeMessage('call:disposition')
|
||||||
async handleDisposition(
|
async handleDisposition(
|
||||||
@ConnectedSocket() client: Socket,
|
@ConnectedSocket() client: Socket,
|
||||||
@MessageBody() payload: DispositionPayload,
|
@MessageBody() payload: DispositionPayload,
|
||||||
) {
|
) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Disposition received from ${payload.agentName}: ${payload.disposition}`,
|
`Disposition received from ${payload.agentName}: ${payload.disposition}`,
|
||||||
);
|
);
|
||||||
await this.callEventsService.handleDisposition(payload);
|
await this.callEventsService.handleDisposition(payload);
|
||||||
client.emit('call:disposition:ack', {
|
client.emit('call:disposition:ack', {
|
||||||
status: 'saved',
|
status: 'saved',
|
||||||
callSid: payload.callSid,
|
callSid: payload.callSid,
|
||||||
});
|
});
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleConnection(client: Socket) {
|
handleConnection(client: Socket) {
|
||||||
this.logger.log(`Client connected: ${client.id}`);
|
this.logger.log(`Client connected: ${client.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDisconnect(client: Socket) {
|
handleDisconnect(client: Socket) {
|
||||||
this.logger.log(`Client disconnected: ${client.id}`);
|
this.logger.log(`Client disconnected: ${client.id}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import { CallEventsGateway } from './call-events.gateway';
|
|||||||
import { CallLookupController } from './call-lookup.controller';
|
import { CallLookupController } from './call-lookup.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule, AiModule],
|
imports: [PlatformModule, AiModule],
|
||||||
controllers: [CallLookupController],
|
controllers: [CallLookupController],
|
||||||
providers: [CallEventsService, CallEventsGateway],
|
providers: [CallEventsService, CallEventsGateway],
|
||||||
exports: [CallEventsService, CallEventsGateway],
|
exports: [CallEventsService, CallEventsGateway],
|
||||||
})
|
})
|
||||||
export class CallEventsModule {}
|
export class CallEventsModule {}
|
||||||
|
|||||||
@@ -4,231 +4,217 @@ import { AiEnrichmentService } from '../ai/ai-enrichment.service';
|
|||||||
import { CallEventsGateway } from './call-events.gateway';
|
import { CallEventsGateway } from './call-events.gateway';
|
||||||
import type { CallEvent } from '../exotel/exotel.types';
|
import type { CallEvent } from '../exotel/exotel.types';
|
||||||
import type {
|
import type {
|
||||||
EnrichedCallEvent,
|
EnrichedCallEvent,
|
||||||
DispositionPayload,
|
DispositionPayload,
|
||||||
} from './call-events.types';
|
} from './call-events.types';
|
||||||
|
|
||||||
const DISPOSITION_TO_LEAD_STATUS: Record<string, string> = {
|
const DISPOSITION_TO_LEAD_STATUS: Record<string, string> = {
|
||||||
APPOINTMENT_BOOKED: 'APPOINTMENT_SET',
|
APPOINTMENT_BOOKED: 'APPOINTMENT_SET',
|
||||||
FOLLOW_UP_SCHEDULED: 'CONTACTED',
|
FOLLOW_UP_SCHEDULED: 'CONTACTED',
|
||||||
INFO_PROVIDED: 'CONTACTED',
|
INFO_PROVIDED: 'CONTACTED',
|
||||||
CALLBACK_REQUESTED: 'CONTACTED',
|
CALLBACK_REQUESTED: 'CONTACTED',
|
||||||
WRONG_NUMBER: 'LOST',
|
WRONG_NUMBER: 'LOST',
|
||||||
NO_ANSWER: 'CONTACTED',
|
NO_ANSWER: 'CONTACTED',
|
||||||
NOT_INTERESTED: 'LOST',
|
NOT_INTERESTED: 'LOST',
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CallEventsService {
|
export class CallEventsService {
|
||||||
private readonly logger = new Logger(CallEventsService.name);
|
private readonly logger = new Logger(CallEventsService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly platform: PlatformGraphqlService,
|
private readonly platform: PlatformGraphqlService,
|
||||||
private readonly ai: AiEnrichmentService,
|
private readonly ai: AiEnrichmentService,
|
||||||
@Inject(forwardRef(() => CallEventsGateway))
|
@Inject(forwardRef(() => CallEventsGateway))
|
||||||
private readonly gateway: CallEventsGateway,
|
private readonly gateway: CallEventsGateway,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handleIncomingCall(callEvent: CallEvent): Promise<void> {
|
async handleIncomingCall(callEvent: CallEvent): Promise<void> {
|
||||||
|
this.logger.log(
|
||||||
|
`Processing incoming call from ${callEvent.callerPhone} to agent ${callEvent.agentName}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1. Lookup lead by phone
|
||||||
|
let lead = null;
|
||||||
|
try {
|
||||||
|
lead = await this.platform.findLeadByPhone(callEvent.callerPhone);
|
||||||
|
if (lead) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Processing incoming call from ${callEvent.callerPhone} to agent ${callEvent.agentName}`,
|
`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(`Lead lookup failed: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Lookup lead by phone
|
// 2. AI enrichment (if lead found and no existing summary)
|
||||||
let lead = null;
|
if (lead && !lead.aiSummary) {
|
||||||
|
try {
|
||||||
|
const activities = await this.platform.getLeadActivities(lead.id, 5);
|
||||||
|
const enrichment = await this.ai.enrichLead({
|
||||||
|
firstName: lead.contactName?.firstName,
|
||||||
|
lastName: lead.contactName?.lastName,
|
||||||
|
leadSource: lead.leadSource ?? undefined,
|
||||||
|
interestedService: lead.interestedService ?? undefined,
|
||||||
|
leadStatus: lead.leadStatus ?? undefined,
|
||||||
|
contactAttempts: lead.contactAttempts ?? undefined,
|
||||||
|
createdAt: lead.createdAt,
|
||||||
|
activities: activities.map((a) => ({
|
||||||
|
activityType: a.activityType ?? '',
|
||||||
|
summary: a.summary ?? '',
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Persist AI enrichment back to platform
|
||||||
|
await this.platform.updateLead(lead.id, enrichment);
|
||||||
|
lead.aiSummary = enrichment.aiSummary;
|
||||||
|
lead.aiSuggestedAction = enrichment.aiSuggestedAction;
|
||||||
|
|
||||||
|
this.logger.log(`AI enrichment applied for lead ${lead.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`AI enrichment failed: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Get recent activities for display
|
||||||
|
let recentActivities: {
|
||||||
|
activityType: string;
|
||||||
|
summary: string;
|
||||||
|
occurredAt: string;
|
||||||
|
performedBy: string;
|
||||||
|
}[] = [];
|
||||||
|
if (lead) {
|
||||||
|
try {
|
||||||
|
const activities = await this.platform.getLeadActivities(lead.id, 3);
|
||||||
|
recentActivities = activities.map((a) => ({
|
||||||
|
activityType: a.activityType ?? '',
|
||||||
|
summary: a.summary ?? '',
|
||||||
|
occurredAt: a.occurredAt ?? '',
|
||||||
|
performedBy: a.performedBy ?? '',
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to fetch activities: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Build enriched event
|
||||||
|
const daysSinceCreation = lead?.createdAt
|
||||||
|
? Math.floor(
|
||||||
|
(Date.now() - new Date(lead.createdAt).getTime()) /
|
||||||
|
(1000 * 60 * 60 * 24),
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const enrichedEvent: EnrichedCallEvent = {
|
||||||
|
callSid: callEvent.exotelCallSid,
|
||||||
|
eventType: callEvent.eventType,
|
||||||
|
lead: lead
|
||||||
|
? {
|
||||||
|
id: lead.id,
|
||||||
|
firstName: lead.contactName?.firstName ?? 'Unknown',
|
||||||
|
lastName: lead.contactName?.lastName ?? '',
|
||||||
|
phone: lead.contactPhone?.[0]
|
||||||
|
? `${lead.contactPhone[0].callingCode} ${lead.contactPhone[0].number}`
|
||||||
|
: callEvent.callerPhone,
|
||||||
|
email: lead.contactEmail?.[0]?.address,
|
||||||
|
source: lead.leadSource ?? undefined,
|
||||||
|
status: lead.leadStatus ?? undefined,
|
||||||
|
interestedService: lead.interestedService ?? undefined,
|
||||||
|
age: daysSinceCreation,
|
||||||
|
aiSummary: lead.aiSummary ?? undefined,
|
||||||
|
aiSuggestedAction: lead.aiSuggestedAction ?? undefined,
|
||||||
|
recentActivities,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
callerPhone: callEvent.callerPhone,
|
||||||
|
agentName: callEvent.agentName,
|
||||||
|
timestamp: callEvent.timestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 5. Push to agent's browser via WebSocket
|
||||||
|
this.gateway.pushCallEvent(callEvent.agentName, enrichedEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleCallEnded(callEvent: CallEvent): Promise<void> {
|
||||||
|
this.logger.log(`Call ended: ${callEvent.exotelCallSid}`);
|
||||||
|
|
||||||
|
const enrichedEvent: EnrichedCallEvent = {
|
||||||
|
callSid: callEvent.exotelCallSid,
|
||||||
|
eventType: 'ended',
|
||||||
|
lead: null,
|
||||||
|
callerPhone: callEvent.callerPhone,
|
||||||
|
agentName: callEvent.agentName,
|
||||||
|
timestamp: callEvent.timestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.gateway.pushCallEvent(callEvent.agentName, enrichedEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDisposition(payload: DispositionPayload): Promise<void> {
|
||||||
|
this.logger.log(
|
||||||
|
`Processing disposition: ${payload.disposition} for call ${payload.callSid}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1. Create Call record in platform
|
||||||
|
try {
|
||||||
|
await this.platform.createCall({
|
||||||
|
callDirection: 'INBOUND',
|
||||||
|
callStatus: 'COMPLETED',
|
||||||
|
callerNumber: payload.callerPhone
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
number: payload.callerPhone.replace(/\D/g, ''),
|
||||||
|
callingCode: '+91',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: undefined,
|
||||||
|
agentName: payload.agentName,
|
||||||
|
startedAt: payload.startedAt,
|
||||||
|
endedAt: new Date().toISOString(),
|
||||||
|
durationSeconds: payload.duration,
|
||||||
|
disposition: payload.disposition,
|
||||||
|
callNotes: payload.notes || undefined,
|
||||||
|
leadId: payload.leadId || undefined,
|
||||||
|
});
|
||||||
|
this.logger.log(`Call record created for ${payload.callSid}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to create call record: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Update lead status based on disposition
|
||||||
|
if (payload.leadId) {
|
||||||
|
const newStatus = DISPOSITION_TO_LEAD_STATUS[payload.disposition];
|
||||||
|
if (newStatus) {
|
||||||
try {
|
try {
|
||||||
lead = await this.platform.findLeadByPhone(callEvent.callerPhone);
|
await this.platform.updateLead(payload.leadId, {
|
||||||
if (lead) {
|
leadStatus: newStatus,
|
||||||
this.logger.log(
|
lastContactedAt: new Date().toISOString(),
|
||||||
`Matched lead: ${lead.contactName?.firstName} ${lead.contactName?.lastName} (${lead.id})`,
|
});
|
||||||
);
|
this.logger.log(
|
||||||
} else {
|
`Lead ${payload.leadId} status updated to ${newStatus}`,
|
||||||
this.logger.log(
|
);
|
||||||
`No lead found for phone ${callEvent.callerPhone}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Lead lookup failed: ${error}`);
|
this.logger.error(`Failed to update lead: ${error}`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2. AI enrichment (if lead found and no existing summary)
|
// 3. Create lead activity
|
||||||
if (lead && !lead.aiSummary) {
|
try {
|
||||||
try {
|
await this.platform.createLeadActivity({
|
||||||
const activities = await this.platform.getLeadActivities(
|
activityType: 'CALL_RECEIVED',
|
||||||
lead.id,
|
summary: `Inbound call — ${payload.disposition.replace(/_/g, ' ')}`,
|
||||||
5,
|
occurredAt: new Date().toISOString(),
|
||||||
);
|
performedBy: payload.agentName,
|
||||||
const enrichment = await this.ai.enrichLead({
|
channel: 'PHONE',
|
||||||
firstName: lead.contactName?.firstName,
|
durationSeconds: payload.duration,
|
||||||
lastName: lead.contactName?.lastName,
|
leadId: payload.leadId,
|
||||||
leadSource: lead.leadSource ?? undefined,
|
});
|
||||||
interestedService: lead.interestedService ?? undefined,
|
this.logger.log(`Lead activity logged for ${payload.leadId}`);
|
||||||
leadStatus: lead.leadStatus ?? undefined,
|
} catch (error) {
|
||||||
contactAttempts: lead.contactAttempts ?? undefined,
|
this.logger.error(`Failed to create lead activity: ${error}`);
|
||||||
createdAt: lead.createdAt,
|
}
|
||||||
activities: activities.map((a) => ({
|
|
||||||
activityType: a.activityType ?? '',
|
|
||||||
summary: a.summary ?? '',
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Persist AI enrichment back to platform
|
|
||||||
await this.platform.updateLead(lead.id, enrichment);
|
|
||||||
lead.aiSummary = enrichment.aiSummary;
|
|
||||||
lead.aiSuggestedAction = enrichment.aiSuggestedAction;
|
|
||||||
|
|
||||||
this.logger.log(`AI enrichment applied for lead ${lead.id}`);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`AI enrichment failed: ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Get recent activities for display
|
|
||||||
let recentActivities: {
|
|
||||||
activityType: string;
|
|
||||||
summary: string;
|
|
||||||
occurredAt: string;
|
|
||||||
performedBy: string;
|
|
||||||
}[] = [];
|
|
||||||
if (lead) {
|
|
||||||
try {
|
|
||||||
const activities = await this.platform.getLeadActivities(
|
|
||||||
lead.id,
|
|
||||||
3,
|
|
||||||
);
|
|
||||||
recentActivities = activities.map((a) => ({
|
|
||||||
activityType: a.activityType ?? '',
|
|
||||||
summary: a.summary ?? '',
|
|
||||||
occurredAt: a.occurredAt ?? '',
|
|
||||||
performedBy: a.performedBy ?? '',
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to fetch activities: ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Build enriched event
|
|
||||||
const daysSinceCreation = lead?.createdAt
|
|
||||||
? Math.floor(
|
|
||||||
(Date.now() - new Date(lead.createdAt).getTime()) /
|
|
||||||
(1000 * 60 * 60 * 24),
|
|
||||||
)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const enrichedEvent: EnrichedCallEvent = {
|
|
||||||
callSid: callEvent.exotelCallSid,
|
|
||||||
eventType: callEvent.eventType,
|
|
||||||
lead: lead
|
|
||||||
? {
|
|
||||||
id: lead.id,
|
|
||||||
firstName: lead.contactName?.firstName ?? 'Unknown',
|
|
||||||
lastName: lead.contactName?.lastName ?? '',
|
|
||||||
phone: lead.contactPhone?.[0]
|
|
||||||
? `${lead.contactPhone[0].callingCode} ${lead.contactPhone[0].number}`
|
|
||||||
: callEvent.callerPhone,
|
|
||||||
email: lead.contactEmail?.[0]?.address,
|
|
||||||
source: lead.leadSource ?? undefined,
|
|
||||||
status: lead.leadStatus ?? undefined,
|
|
||||||
interestedService:
|
|
||||||
lead.interestedService ?? undefined,
|
|
||||||
age: daysSinceCreation,
|
|
||||||
aiSummary: lead.aiSummary ?? undefined,
|
|
||||||
aiSuggestedAction:
|
|
||||||
lead.aiSuggestedAction ?? undefined,
|
|
||||||
recentActivities,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
callerPhone: callEvent.callerPhone,
|
|
||||||
agentName: callEvent.agentName,
|
|
||||||
timestamp: callEvent.timestamp,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 5. Push to agent's browser via WebSocket
|
|
||||||
this.gateway.pushCallEvent(callEvent.agentName, enrichedEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleCallEnded(callEvent: CallEvent): Promise<void> {
|
|
||||||
this.logger.log(`Call ended: ${callEvent.exotelCallSid}`);
|
|
||||||
|
|
||||||
const enrichedEvent: EnrichedCallEvent = {
|
|
||||||
callSid: callEvent.exotelCallSid,
|
|
||||||
eventType: 'ended',
|
|
||||||
lead: null,
|
|
||||||
callerPhone: callEvent.callerPhone,
|
|
||||||
agentName: callEvent.agentName,
|
|
||||||
timestamp: callEvent.timestamp,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.gateway.pushCallEvent(callEvent.agentName, enrichedEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleDisposition(payload: DispositionPayload): Promise<void> {
|
|
||||||
this.logger.log(
|
|
||||||
`Processing disposition: ${payload.disposition} for call ${payload.callSid}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 1. Create Call record in platform
|
|
||||||
try {
|
|
||||||
await this.platform.createCall({
|
|
||||||
callDirection: 'INBOUND',
|
|
||||||
callStatus: 'COMPLETED',
|
|
||||||
callerNumber: payload.callerPhone
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
number: payload.callerPhone.replace(/\D/g, ''),
|
|
||||||
callingCode: '+91',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: undefined,
|
|
||||||
agentName: payload.agentName,
|
|
||||||
startedAt: payload.startedAt,
|
|
||||||
endedAt: new Date().toISOString(),
|
|
||||||
durationSeconds: payload.duration,
|
|
||||||
disposition: payload.disposition,
|
|
||||||
callNotes: payload.notes || undefined,
|
|
||||||
leadId: payload.leadId || undefined,
|
|
||||||
});
|
|
||||||
this.logger.log(`Call record created for ${payload.callSid}`);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to create call record: ${error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Update lead status based on disposition
|
|
||||||
if (payload.leadId) {
|
|
||||||
const newStatus = DISPOSITION_TO_LEAD_STATUS[payload.disposition];
|
|
||||||
if (newStatus) {
|
|
||||||
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}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,38 @@
|
|||||||
export type EnrichedCallEvent = {
|
export type EnrichedCallEvent = {
|
||||||
callSid: string;
|
callSid: string;
|
||||||
eventType: 'ringing' | 'answered' | 'ended';
|
eventType: 'ringing' | 'answered' | 'ended';
|
||||||
lead: {
|
lead: {
|
||||||
id: string;
|
id: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
source?: string;
|
source?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
campaign?: string;
|
campaign?: string;
|
||||||
interestedService?: string;
|
interestedService?: string;
|
||||||
age: number;
|
age: number;
|
||||||
aiSummary?: string;
|
aiSummary?: string;
|
||||||
aiSuggestedAction?: string;
|
aiSuggestedAction?: string;
|
||||||
recentActivities: {
|
recentActivities: {
|
||||||
activityType: string;
|
activityType: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
occurredAt: string;
|
occurredAt: string;
|
||||||
performedBy: string;
|
performedBy: string;
|
||||||
}[];
|
}[];
|
||||||
} | null;
|
} | null;
|
||||||
callerPhone: string;
|
callerPhone: string;
|
||||||
agentName: string;
|
agentName: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DispositionPayload = {
|
export type DispositionPayload = {
|
||||||
callSid: string;
|
callSid: string;
|
||||||
leadId: string | null;
|
leadId: string | null;
|
||||||
disposition: string;
|
disposition: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
agentName: string;
|
agentName: string;
|
||||||
callerPhone: string;
|
callerPhone: string;
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,88 +1,105 @@
|
|||||||
import { Controller, Post, Body, Logger, Headers, HttpException } from '@nestjs/common';
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
Logger,
|
||||||
|
Headers,
|
||||||
|
HttpException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
import { AiEnrichmentService } from '../ai/ai-enrichment.service';
|
import { AiEnrichmentService } from '../ai/ai-enrichment.service';
|
||||||
|
|
||||||
@Controller('api/call')
|
@Controller('api/call')
|
||||||
export class CallLookupController {
|
export class CallLookupController {
|
||||||
private readonly logger = new Logger(CallLookupController.name);
|
private readonly logger = new Logger(CallLookupController.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly platform: PlatformGraphqlService,
|
private readonly platform: PlatformGraphqlService,
|
||||||
private readonly ai: AiEnrichmentService,
|
private readonly ai: AiEnrichmentService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('lookup')
|
@Post('lookup')
|
||||||
async lookupCaller(
|
async lookupCaller(
|
||||||
@Body() body: { phoneNumber: string },
|
@Body() body: { phoneNumber: string },
|
||||||
@Headers('authorization') authHeader: string,
|
@Headers('authorization') authHeader: string,
|
||||||
) {
|
) {
|
||||||
if (!authHeader) throw new HttpException('Authorization required', 401);
|
if (!authHeader) throw new HttpException('Authorization required', 401);
|
||||||
if (!body.phoneNumber) throw new HttpException('phoneNumber required', 400);
|
if (!body.phoneNumber) throw new HttpException('phoneNumber required', 400);
|
||||||
|
|
||||||
const phone = body.phoneNumber.replace(/^0+/, '');
|
const phone = body.phoneNumber.replace(/^0+/, '');
|
||||||
this.logger.log(`Looking up caller: ${phone}`);
|
this.logger.log(`Looking up caller: ${phone}`);
|
||||||
|
|
||||||
// Query platform for leads matching this phone number
|
// Query platform for leads matching this phone number
|
||||||
let lead = null;
|
let lead = null;
|
||||||
let activities: any[] = [];
|
let activities: any[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
lead = await this.platform.findLeadByPhoneWithToken(phone, authHeader);
|
lead = await this.platform.findLeadByPhoneWithToken(phone, authHeader);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Lead lookup failed: ${err}`);
|
this.logger.warn(`Lead lookup failed: ${err}`);
|
||||||
}
|
|
||||||
|
|
||||||
if (lead) {
|
|
||||||
this.logger.log(`Matched lead: ${lead.id} — ${lead.contactName?.firstName} ${lead.contactName?.lastName}`);
|
|
||||||
|
|
||||||
// Get recent activities
|
|
||||||
try {
|
|
||||||
activities = await this.platform.getLeadActivitiesWithToken(lead.id, authHeader, 5);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(`Activity fetch failed: ${err}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// AI enrichment if no existing summary
|
|
||||||
if (!lead.aiSummary) {
|
|
||||||
try {
|
|
||||||
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: any) => ({
|
|
||||||
activityType: a.activityType ?? '',
|
|
||||||
summary: a.summary ?? '',
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
lead.aiSummary = enrichment.aiSummary;
|
|
||||||
lead.aiSuggestedAction = enrichment.aiSuggestedAction;
|
|
||||||
|
|
||||||
// Persist AI enrichment back to platform
|
|
||||||
try {
|
|
||||||
await this.platform.updateLeadWithToken(lead.id, {
|
|
||||||
aiSummary: enrichment.aiSummary,
|
|
||||||
aiSuggestedAction: enrichment.aiSuggestedAction,
|
|
||||||
}, authHeader);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(`Failed to persist AI enrichment: ${err}`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(`AI enrichment failed: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.logger.log(`No lead found for phone ${phone}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
lead,
|
|
||||||
activities,
|
|
||||||
matched: lead !== null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (lead) {
|
||||||
|
this.logger.log(
|
||||||
|
`Matched lead: ${lead.id} — ${lead.contactName?.firstName} ${lead.contactName?.lastName}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get recent activities
|
||||||
|
try {
|
||||||
|
activities = await this.platform.getLeadActivitiesWithToken(
|
||||||
|
lead.id,
|
||||||
|
authHeader,
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Activity fetch failed: ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI enrichment if no existing summary
|
||||||
|
if (!lead.aiSummary) {
|
||||||
|
try {
|
||||||
|
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: any) => ({
|
||||||
|
activityType: a.activityType ?? '',
|
||||||
|
summary: a.summary ?? '',
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
lead.aiSummary = enrichment.aiSummary;
|
||||||
|
lead.aiSuggestedAction = enrichment.aiSuggestedAction;
|
||||||
|
|
||||||
|
// Persist AI enrichment back to platform
|
||||||
|
try {
|
||||||
|
await this.platform.updateLeadWithToken(
|
||||||
|
lead.id,
|
||||||
|
{
|
||||||
|
aiSummary: enrichment.aiSummary,
|
||||||
|
aiSuggestedAction: enrichment.aiSuggestedAction,
|
||||||
|
},
|
||||||
|
authHeader,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to persist AI enrichment: ${err}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`AI enrichment failed: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.log(`No lead found for phone ${phone}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
lead,
|
||||||
|
activities,
|
||||||
|
matched: lead !== null,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,28 @@
|
|||||||
export default () => ({
|
export default () => ({
|
||||||
port: parseInt(process.env.PORT ?? '4100', 10),
|
port: parseInt(process.env.PORT ?? '4100', 10),
|
||||||
corsOrigin: process.env.CORS_ORIGIN ?? 'http://localhost:5173',
|
corsOrigin: process.env.CORS_ORIGIN ?? 'http://localhost:5173',
|
||||||
platform: {
|
platform: {
|
||||||
graphqlUrl: process.env.PLATFORM_GRAPHQL_URL ?? 'http://localhost:4000/graphql',
|
graphqlUrl:
|
||||||
apiKey: process.env.PLATFORM_API_KEY ?? '',
|
process.env.PLATFORM_GRAPHQL_URL ?? 'http://localhost:4000/graphql',
|
||||||
},
|
apiKey: process.env.PLATFORM_API_KEY ?? '',
|
||||||
exotel: {
|
},
|
||||||
apiKey: process.env.EXOTEL_API_KEY ?? '',
|
exotel: {
|
||||||
apiToken: process.env.EXOTEL_API_TOKEN ?? '',
|
apiKey: process.env.EXOTEL_API_KEY ?? '',
|
||||||
accountSid: process.env.EXOTEL_ACCOUNT_SID ?? '',
|
apiToken: process.env.EXOTEL_API_TOKEN ?? '',
|
||||||
subdomain: process.env.EXOTEL_SUBDOMAIN ?? 'api.exotel.com',
|
accountSid: process.env.EXOTEL_ACCOUNT_SID ?? '',
|
||||||
webhookSecret: process.env.EXOTEL_WEBHOOK_SECRET ?? '',
|
subdomain: process.env.EXOTEL_SUBDOMAIN ?? 'api.exotel.com',
|
||||||
},
|
webhookSecret: process.env.EXOTEL_WEBHOOK_SECRET ?? '',
|
||||||
missedQueue: {
|
},
|
||||||
pollIntervalMs: parseInt(process.env.MISSED_QUEUE_POLL_INTERVAL_MS ?? '30000', 10),
|
missedQueue: {
|
||||||
},
|
pollIntervalMs: parseInt(
|
||||||
ai: {
|
process.env.MISSED_QUEUE_POLL_INTERVAL_MS ?? '30000',
|
||||||
provider: process.env.AI_PROVIDER ?? 'openai',
|
10,
|
||||||
anthropicApiKey: process.env.ANTHROPIC_API_KEY ?? '',
|
),
|
||||||
openaiApiKey: process.env.OPENAI_API_KEY ?? '',
|
},
|
||||||
model: process.env.AI_MODEL ?? 'gpt-4o-mini',
|
ai: {
|
||||||
},
|
provider: process.env.AI_PROVIDER ?? 'openai',
|
||||||
|
anthropicApiKey: process.env.ANTHROPIC_API_KEY ?? '',
|
||||||
|
openaiApiKey: process.env.OPENAI_API_KEY ?? '',
|
||||||
|
model: process.env.AI_MODEL ?? 'gpt-4o-mini',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,26 +5,28 @@ import type { ExotelWebhookPayload } from './exotel.types';
|
|||||||
|
|
||||||
@Controller('webhooks/exotel')
|
@Controller('webhooks/exotel')
|
||||||
export class ExotelController {
|
export class ExotelController {
|
||||||
private readonly logger = new Logger(ExotelController.name);
|
private readonly logger = new Logger(ExotelController.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly exotelService: ExotelService,
|
private readonly exotelService: ExotelService,
|
||||||
private readonly callEventsService: CallEventsService,
|
private readonly callEventsService: CallEventsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('call-status')
|
@Post('call-status')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
async handleCallStatus(@Body() payload: ExotelWebhookPayload) {
|
async handleCallStatus(@Body() payload: ExotelWebhookPayload) {
|
||||||
this.logger.log(`Received Exotel webhook: ${payload.event_details?.event_type}`);
|
this.logger.log(
|
||||||
|
`Received Exotel webhook: ${payload.event_details?.event_type}`,
|
||||||
|
);
|
||||||
|
|
||||||
const callEvent = this.exotelService.parseWebhook(payload);
|
const callEvent = this.exotelService.parseWebhook(payload);
|
||||||
|
|
||||||
if (callEvent.eventType === 'answered') {
|
if (callEvent.eventType === 'answered') {
|
||||||
await this.callEventsService.handleIncomingCall(callEvent);
|
await this.callEventsService.handleIncomingCall(callEvent);
|
||||||
} else if (callEvent.eventType === 'ended') {
|
} else if (callEvent.eventType === 'ended') {
|
||||||
await this.callEventsService.handleCallEnded(callEvent);
|
await this.callEventsService.handleCallEnded(callEvent);
|
||||||
}
|
|
||||||
|
|
||||||
return { status: 'received' };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { status: 'received' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { ExotelController } from './exotel.controller';
|
|||||||
import { ExotelService } from './exotel.service';
|
import { ExotelService } from './exotel.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CallEventsModule],
|
imports: [CallEventsModule],
|
||||||
controllers: [ExotelController],
|
controllers: [ExotelController],
|
||||||
providers: [ExotelService],
|
providers: [ExotelService],
|
||||||
exports: [ExotelService],
|
exports: [ExotelService],
|
||||||
})
|
})
|
||||||
export class ExotelModule {}
|
export class ExotelModule {}
|
||||||
|
|||||||
@@ -3,29 +3,34 @@ import type { ExotelWebhookPayload, CallEvent } from './exotel.types';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExotelService {
|
export class ExotelService {
|
||||||
private readonly logger = new Logger(ExotelService.name);
|
private readonly logger = new Logger(ExotelService.name);
|
||||||
|
|
||||||
parseWebhook(payload: ExotelWebhookPayload): CallEvent {
|
parseWebhook(payload: ExotelWebhookPayload): CallEvent {
|
||||||
const { event_details, call_details } = payload;
|
const { event_details, call_details } = payload;
|
||||||
|
|
||||||
const eventType = event_details.event_type === 'answered' ? 'answered'
|
const eventType =
|
||||||
: event_details.event_type === 'terminal' ? 'ended'
|
event_details.event_type === 'answered'
|
||||||
: 'ringing';
|
? 'answered'
|
||||||
|
: event_details.event_type === 'terminal'
|
||||||
|
? 'ended'
|
||||||
|
: 'ringing';
|
||||||
|
|
||||||
const callEvent: CallEvent = {
|
const callEvent: CallEvent = {
|
||||||
exotelCallSid: call_details.call_sid,
|
exotelCallSid: call_details.call_sid,
|
||||||
eventType,
|
eventType,
|
||||||
direction: call_details.direction,
|
direction: call_details.direction,
|
||||||
callerPhone: call_details.customer_details?.number ?? '',
|
callerPhone: call_details.customer_details?.number ?? '',
|
||||||
agentName: call_details.assigned_agent_details?.name ?? 'Unknown',
|
agentName: call_details.assigned_agent_details?.name ?? 'Unknown',
|
||||||
agentPhone: call_details.assigned_agent_details?.number ?? '',
|
agentPhone: call_details.assigned_agent_details?.number ?? '',
|
||||||
duration: call_details.total_talk_time,
|
duration: call_details.total_talk_time,
|
||||||
recordingUrl: call_details.recordings?.[0]?.url,
|
recordingUrl: call_details.recordings?.[0]?.url,
|
||||||
callStatus: call_details.call_status,
|
callStatus: call_details.call_status,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.logger.log(`Parsed Exotel event: ${eventType} for call ${call_details.call_sid}`);
|
this.logger.log(
|
||||||
return callEvent;
|
`Parsed Exotel event: ${eventType} for call ${call_details.call_sid}`,
|
||||||
}
|
);
|
||||||
|
return callEvent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,35 @@
|
|||||||
// Exotel webhook payload (from their API docs)
|
// Exotel webhook payload (from their API docs)
|
||||||
export type ExotelWebhookPayload = {
|
export type ExotelWebhookPayload = {
|
||||||
event_details: {
|
event_details: {
|
||||||
event_type: 'answered' | 'terminal';
|
event_type: 'answered' | 'terminal';
|
||||||
|
};
|
||||||
|
call_details: {
|
||||||
|
call_sid: string;
|
||||||
|
direction: 'inbound' | 'outbound';
|
||||||
|
call_status?: string;
|
||||||
|
total_talk_time?: number;
|
||||||
|
assigned_agent_details?: {
|
||||||
|
name: string;
|
||||||
|
number: string;
|
||||||
};
|
};
|
||||||
call_details: {
|
customer_details?: {
|
||||||
call_sid: string;
|
number: string;
|
||||||
direction: 'inbound' | 'outbound';
|
name?: string;
|
||||||
call_status?: string;
|
|
||||||
total_talk_time?: number;
|
|
||||||
assigned_agent_details?: {
|
|
||||||
name: string;
|
|
||||||
number: string;
|
|
||||||
};
|
|
||||||
customer_details?: {
|
|
||||||
number: string;
|
|
||||||
name?: string;
|
|
||||||
};
|
|
||||||
recordings?: { url: string }[];
|
|
||||||
};
|
};
|
||||||
|
recordings?: { url: string }[];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Internal call event (normalized)
|
// Internal call event (normalized)
|
||||||
export type CallEvent = {
|
export type CallEvent = {
|
||||||
exotelCallSid: string;
|
exotelCallSid: string;
|
||||||
eventType: 'ringing' | 'answered' | 'ended';
|
eventType: 'ringing' | 'answered' | 'ended';
|
||||||
direction: 'inbound' | 'outbound';
|
direction: 'inbound' | 'outbound';
|
||||||
callerPhone: string;
|
callerPhone: string;
|
||||||
agentName: string;
|
agentName: string;
|
||||||
agentPhone: string;
|
agentPhone: string;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
recordingUrl?: string;
|
recordingUrl?: string;
|
||||||
callStatus?: string;
|
callStatus?: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,45 +1,48 @@
|
|||||||
import { Controller, Post, Req, Res, Logger, HttpException } from '@nestjs/common';
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
Logger,
|
||||||
|
HttpException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
@Controller('graphql')
|
@Controller('graphql')
|
||||||
export class GraphqlProxyController {
|
export class GraphqlProxyController {
|
||||||
private readonly logger = new Logger(GraphqlProxyController.name);
|
private readonly logger = new Logger(GraphqlProxyController.name);
|
||||||
private readonly graphqlUrl: string;
|
private readonly graphqlUrl: string;
|
||||||
|
|
||||||
constructor(private config: ConfigService) {
|
constructor(private config: ConfigService) {
|
||||||
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
|
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async proxy(@Req() req: Request, @Res() res: Response) {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
|
if (!authHeader) {
|
||||||
|
throw new HttpException('Authorization header required', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
try {
|
||||||
async proxy(@Req() req: Request, @Res() res: Response) {
|
const response = await axios.post(this.graphqlUrl, req.body, {
|
||||||
const authHeader = req.headers.authorization;
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: authHeader,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!authHeader) {
|
res.status(response.status).json(response.data);
|
||||||
throw new HttpException('Authorization header required', 401);
|
} catch (error: any) {
|
||||||
}
|
if (error.response) {
|
||||||
|
res.status(error.response.status).json(error.response.data);
|
||||||
try {
|
} else {
|
||||||
const response = await axios.post(
|
this.logger.error(`GraphQL proxy error: ${error.message}`);
|
||||||
this.graphqlUrl,
|
throw new HttpException('Platform unreachable', 503);
|
||||||
req.body,
|
}
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': authHeader,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
res.status(response.status).json(response.data);
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.response) {
|
|
||||||
res.status(error.response.status).json(error.response.data);
|
|
||||||
} else {
|
|
||||||
this.logger.error(`GraphQL proxy error: ${error.message}`);
|
|
||||||
throw new HttpException('Platform unreachable', 503);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ import { Module } from '@nestjs/common';
|
|||||||
import { GraphqlProxyController } from './graphql-proxy.controller';
|
import { GraphqlProxyController } from './graphql-proxy.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [GraphqlProxyController],
|
controllers: [GraphqlProxyController],
|
||||||
})
|
})
|
||||||
export class GraphqlProxyModule {}
|
export class GraphqlProxyModule {}
|
||||||
|
|||||||
@@ -4,35 +4,39 @@ import axios from 'axios';
|
|||||||
|
|
||||||
@Controller('api/health')
|
@Controller('api/health')
|
||||||
export class HealthController {
|
export class HealthController {
|
||||||
private readonly logger = new Logger(HealthController.name);
|
private readonly logger = new Logger(HealthController.name);
|
||||||
private readonly graphqlUrl: string;
|
private readonly graphqlUrl: string;
|
||||||
|
|
||||||
constructor(private config: ConfigService) {
|
constructor(private config: ConfigService) {
|
||||||
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
|
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async check() {
|
||||||
|
let platformReachable = false;
|
||||||
|
let platformLatency = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const start = Date.now();
|
||||||
|
await axios.post(
|
||||||
|
this.graphqlUrl,
|
||||||
|
{ query: '{ __typename }' },
|
||||||
|
{
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
timeout: 5000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
platformLatency = Date.now() - start;
|
||||||
|
platformReachable = true;
|
||||||
|
} catch {
|
||||||
|
platformReachable = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
return {
|
||||||
async check() {
|
status: platformReachable ? 'ok' : 'degraded',
|
||||||
let platformReachable = false;
|
platform: { reachable: platformReachable, latencyMs: platformLatency },
|
||||||
let platformLatency = 0;
|
ozonetel: { configured: !!process.env.EXOTEL_API_KEY },
|
||||||
|
ai: { configured: !!process.env.ANTHROPIC_API_KEY },
|
||||||
try {
|
};
|
||||||
const start = Date.now();
|
}
|
||||||
await axios.post(this.graphqlUrl, { query: '{ __typename }' }, {
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
timeout: 5000,
|
|
||||||
});
|
|
||||||
platformLatency = Date.now() - start;
|
|
||||||
platformReachable = true;
|
|
||||||
} catch {
|
|
||||||
platformReachable = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: platformReachable ? 'ok' : 'degraded',
|
|
||||||
platform: { reachable: platformReachable, latencyMs: platformLatency },
|
|
||||||
ozonetel: { configured: !!process.env.EXOTEL_API_KEY },
|
|
||||||
ai: { configured: !!process.env.ANTHROPIC_API_KEY },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ import { Module } from '@nestjs/common';
|
|||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
})
|
})
|
||||||
export class HealthModule {}
|
export class HealthModule {}
|
||||||
|
|||||||
32
src/main.ts
32
src/main.ts
@@ -1,18 +1,32 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
const config = app.get(ConfigService);
|
const config = app.get(ConfigService);
|
||||||
|
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: config.get('corsOrigin'),
|
origin: config.get('corsOrigin'),
|
||||||
credentials: true,
|
credentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const port = config.get('port');
|
const swaggerConfig = new DocumentBuilder()
|
||||||
await app.listen(port);
|
.setTitle('Helix Engage Server')
|
||||||
console.log(`Helix Engage Server running on port ${port}`);
|
.setDescription(
|
||||||
|
'Sidecar API — Ozonetel telephony + FortyTwo platform bridge',
|
||||||
|
)
|
||||||
|
.setVersion('1.0')
|
||||||
|
.addBearerAuth()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const document = SwaggerModule.createDocument(app, swaggerConfig);
|
||||||
|
SwaggerModule.setup('api/docs', app, document);
|
||||||
|
|
||||||
|
const port = config.get('port');
|
||||||
|
await app.listen(port);
|
||||||
|
console.log(`Helix Engage Server running on port ${port}`);
|
||||||
|
console.log(`Swagger UI: http://localhost:${port}/api/docs`);
|
||||||
}
|
}
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
@@ -3,49 +3,53 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
|
|
||||||
@Controller('kookoo')
|
@Controller('kookoo')
|
||||||
export class KookooIvrController {
|
export class KookooIvrController {
|
||||||
private readonly logger = new Logger(KookooIvrController.name);
|
private readonly logger = new Logger(KookooIvrController.name);
|
||||||
private readonly sipId: string;
|
private readonly sipId: string;
|
||||||
private readonly callerId: string;
|
private readonly callerId: string;
|
||||||
|
|
||||||
constructor(private config: ConfigService) {
|
constructor(private config: ConfigService) {
|
||||||
this.sipId = process.env.OZONETEL_SIP_ID ?? '523590';
|
this.sipId = process.env.OZONETEL_SIP_ID ?? '523590';
|
||||||
this.callerId = process.env.OZONETEL_DID ?? '918041763265';
|
this.callerId = process.env.OZONETEL_DID ?? '918041763265';
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('ivr')
|
@Get('ivr')
|
||||||
@Header('Content-Type', 'application/xml')
|
@Header('Content-Type', 'application/xml')
|
||||||
handleIvr(@Query() query: Record<string, any>): string {
|
handleIvr(@Query() query: Record<string, any>): string {
|
||||||
const event = query.event ?? '';
|
const event = query.event ?? '';
|
||||||
const sid = query.sid ?? '';
|
const sid = query.sid ?? '';
|
||||||
const cid = query.cid ?? '';
|
const cid = query.cid ?? '';
|
||||||
const status = query.status ?? '';
|
const status = query.status ?? '';
|
||||||
|
|
||||||
this.logger.log(`Kookoo IVR: event=${event} sid=${sid} cid=${cid} status=${status}`);
|
this.logger.log(
|
||||||
|
`Kookoo IVR: event=${event} sid=${sid} cid=${cid} status=${status}`,
|
||||||
|
);
|
||||||
|
|
||||||
// New outbound call — customer answered, put them in a conference room
|
// New outbound call — customer answered, put them in a conference room
|
||||||
// The room ID is based on the call SID so we can join from the browser
|
// The room ID is based on the call SID so we can join from the browser
|
||||||
if (event === 'NewCall') {
|
if (event === 'NewCall') {
|
||||||
this.logger.log(`Customer ${cid} answered — dialing DID ${this.callerId} to route to agent`);
|
this.logger.log(
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
`Customer ${cid} answered — dialing DID ${this.callerId} to route to agent`,
|
||||||
|
);
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<response>
|
<response>
|
||||||
<dial record="true" timeout="30" moh="ring">${this.callerId}</dial>
|
<dial record="true" timeout="30" moh="ring">${this.callerId}</dial>
|
||||||
</response>`;
|
</response>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Conference event — user left with #
|
// Conference event — user left with #
|
||||||
if (event === 'conference' || event === 'Conference') {
|
if (event === 'conference' || event === 'Conference') {
|
||||||
this.logger.log(`Conference event: status=${status}`);
|
this.logger.log(`Conference event: status=${status}`);
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<response>
|
|
||||||
<hangup/>
|
|
||||||
</response>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dial or Disconnect
|
|
||||||
this.logger.log(`Call ended: event=${event}`);
|
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<response>
|
<response>
|
||||||
<hangup/>
|
<hangup/>
|
||||||
</response>`;
|
</response>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dial or Disconnect
|
||||||
|
this.logger.log(`Call ended: event=${event}`);
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<response>
|
||||||
|
<hangup/>
|
||||||
|
</response>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
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 { ConfigService } from '@nestjs/config';
|
||||||
import { OzonetelAgentService } from './ozonetel-agent.service';
|
import { OzonetelAgentService } from './ozonetel-agent.service';
|
||||||
import { MissedQueueService } from '../worklist/missed-queue.service';
|
import { MissedQueueService } from '../worklist/missed-queue.service';
|
||||||
@@ -6,328 +14,385 @@ import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
|||||||
|
|
||||||
@Controller('api/ozonetel')
|
@Controller('api/ozonetel')
|
||||||
export class OzonetelAgentController {
|
export class OzonetelAgentController {
|
||||||
private readonly logger = new Logger(OzonetelAgentController.name);
|
private readonly logger = new Logger(OzonetelAgentController.name);
|
||||||
private readonly defaultAgentId: string;
|
private readonly defaultAgentId: string;
|
||||||
private readonly defaultAgentPassword: string;
|
private readonly defaultAgentPassword: string;
|
||||||
|
|
||||||
private readonly defaultSipId: string;
|
private readonly defaultSipId: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly ozonetelAgent: OzonetelAgentService,
|
private readonly ozonetelAgent: OzonetelAgentService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly missedQueue: MissedQueueService,
|
private readonly missedQueue: MissedQueueService,
|
||||||
private readonly platform: PlatformGraphqlService,
|
private readonly platform: PlatformGraphqlService,
|
||||||
) {
|
) {
|
||||||
this.defaultAgentId = config.get<string>('OZONETEL_AGENT_ID') ?? 'agent3';
|
this.defaultAgentId = config.get<string>('OZONETEL_AGENT_ID') ?? 'agent3';
|
||||||
this.defaultAgentPassword = config.get<string>('OZONETEL_AGENT_PASSWORD') ?? '';
|
this.defaultAgentPassword =
|
||||||
this.defaultSipId = config.get<string>('OZONETEL_SIP_ID') ?? '521814';
|
config.get<string>('OZONETEL_AGENT_PASSWORD') ?? '';
|
||||||
|
this.defaultSipId = config.get<string>('OZONETEL_SIP_ID') ?? '521814';
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('agent-login')
|
||||||
|
async agentLogin(
|
||||||
|
@Body()
|
||||||
|
body: {
|
||||||
|
agentId: string;
|
||||||
|
password: string;
|
||||||
|
phoneNumber: string;
|
||||||
|
mode?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
this.logger.log(`Agent login request for ${body.agentId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.ozonetelAgent.loginAgent(body);
|
||||||
|
return result;
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new HttpException(
|
||||||
|
error.response?.data?.message ?? 'Agent login failed',
|
||||||
|
error.response?.status ?? 500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('agent-logout')
|
||||||
|
async agentLogout(@Body() body: { agentId: string; password: string }) {
|
||||||
|
this.logger.log(`Agent logout request for ${body.agentId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.ozonetelAgent.logoutAgent(body);
|
||||||
|
return result;
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new HttpException(
|
||||||
|
error.response?.data?.message ?? 'Agent logout failed',
|
||||||
|
error.response?.status ?? 500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('agent-state')
|
||||||
|
async agentState(
|
||||||
|
@Body() body: { state: 'Ready' | 'Pause'; pauseReason?: string },
|
||||||
|
) {
|
||||||
|
if (!body.state) {
|
||||||
|
throw new HttpException('state required', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('agent-login')
|
this.logger.log(
|
||||||
async agentLogin(
|
`Agent state change: ${this.defaultAgentId} → ${body.state} (${body.pauseReason ?? ''})`,
|
||||||
@Body() body: { agentId: string; password: string; phoneNumber: string; mode?: string },
|
);
|
||||||
) {
|
|
||||||
this.logger.log(`Agent login request for ${body.agentId}`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.ozonetelAgent.loginAgent(body);
|
const result = await this.ozonetelAgent.changeAgentState({
|
||||||
return result;
|
agentId: this.defaultAgentId,
|
||||||
} catch (error: any) {
|
state: body.state,
|
||||||
throw new HttpException(
|
pauseReason: body.pauseReason,
|
||||||
error.response?.data?.message ?? 'Agent login failed',
|
});
|
||||||
error.response?.status ?? 500,
|
return result;
|
||||||
);
|
} catch (error: any) {
|
||||||
}
|
const message =
|
||||||
|
error.response?.data?.message ?? error.message ?? 'State change failed';
|
||||||
|
return { status: 'error', message };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('agent-logout')
|
// Auto-assign missed call when agent goes Ready
|
||||||
async agentLogout(
|
if (body.state === 'Ready') {
|
||||||
@Body() body: { agentId: string; password: string },
|
try {
|
||||||
) {
|
const assigned = await this.missedQueue.assignNext(this.defaultAgentId);
|
||||||
this.logger.log(`Agent logout request for ${body.agentId}`);
|
if (assigned) {
|
||||||
|
return {
|
||||||
try {
|
status: 'ok',
|
||||||
const result = await this.ozonetelAgent.logoutAgent(body);
|
message: `State changed to Ready. Assigned missed call ${assigned.id}`,
|
||||||
return result;
|
assignedCall: assigned,
|
||||||
} catch (error: any) {
|
};
|
||||||
throw new HttpException(
|
|
||||||
error.response?.data?.message ?? 'Agent logout failed',
|
|
||||||
error.response?.status ?? 500,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
} 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('agent-state')
|
this.logger.log(
|
||||||
async agentState(
|
`Dispose: ucid=${body.ucid} disposition=${body.disposition}`,
|
||||||
@Body() body: { state: 'Ready' | 'Pause'; pauseReason?: string },
|
);
|
||||||
) {
|
|
||||||
if (!body.state) {
|
|
||||||
throw new HttpException('state required', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Agent state change: ${this.defaultAgentId} → ${body.state} (${body.pauseReason ?? ''})`);
|
const ozonetelDisposition = this.mapToOzonetelDisposition(body.disposition);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.ozonetelAgent.changeAgentState({
|
const result = await this.ozonetelAgent.setDisposition({
|
||||||
agentId: this.defaultAgentId,
|
agentId: this.defaultAgentId,
|
||||||
state: body.state,
|
ucid: body.ucid,
|
||||||
pauseReason: body.pauseReason,
|
disposition: ozonetelDisposition,
|
||||||
});
|
});
|
||||||
return result;
|
} catch (error: any) {
|
||||||
} catch (error: any) {
|
const message =
|
||||||
const message = error.response?.data?.message ?? error.message ?? 'State change failed';
|
error.response?.data?.message ?? error.message ?? 'Disposition failed';
|
||||||
return { status: 'error', message };
|
this.logger.error(`Dispose failed: ${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')
|
// Handle missed call callback status update
|
||||||
async agentReady() {
|
if (body.missedCallId) {
|
||||||
this.logger.log(`Force ready: logging out and back in agent ${this.defaultAgentId}`);
|
const statusMap: Record<string, string> = {
|
||||||
|
APPOINTMENT_BOOKED: 'CALLBACK_COMPLETED',
|
||||||
|
INFO_PROVIDED: 'CALLBACK_COMPLETED',
|
||||||
|
FOLLOW_UP_SCHEDULED: 'CALLBACK_COMPLETED',
|
||||||
|
CALLBACK_REQUESTED: 'CALLBACK_COMPLETED',
|
||||||
|
WRONG_NUMBER: 'WRONG_NUMBER',
|
||||||
|
};
|
||||||
|
const newStatus = statusMap[body.disposition];
|
||||||
|
if (newStatus) {
|
||||||
try {
|
try {
|
||||||
await this.ozonetelAgent.logoutAgent({
|
await this.platform.query<any>(
|
||||||
agentId: this.defaultAgentId,
|
`mutation { updateCall(id: "${body.missedCallId}", data: { callbackstatus: ${newStatus} }) { id } }`,
|
||||||
password: this.defaultAgentPassword,
|
);
|
||||||
});
|
|
||||||
const result = await this.ozonetelAgent.loginAgent({
|
|
||||||
agentId: this.defaultAgentId,
|
|
||||||
password: this.defaultAgentPassword,
|
|
||||||
phoneNumber: this.defaultSipId,
|
|
||||||
mode: 'blended',
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
} catch (error: any) {
|
|
||||||
const message = error.response?.data?.message ?? error.message ?? 'Force ready failed';
|
|
||||||
this.logger.error(`Force ready failed: ${message}`);
|
|
||||||
throw new HttpException(message, error.response?.status ?? 502);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('dispose')
|
|
||||||
async dispose(
|
|
||||||
@Body() body: {
|
|
||||||
ucid: string;
|
|
||||||
disposition: string;
|
|
||||||
callerPhone?: string;
|
|
||||||
direction?: string;
|
|
||||||
durationSec?: number;
|
|
||||||
leadId?: string;
|
|
||||||
notes?: string;
|
|
||||||
missedCallId?: string;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
if (!body.ucid || !body.disposition) {
|
|
||||||
throw new HttpException('ucid and disposition required', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Dispose: ucid=${body.ucid} disposition=${body.disposition}`);
|
|
||||||
|
|
||||||
const ozonetelDisposition = this.mapToOzonetelDisposition(body.disposition);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await this.ozonetelAgent.setDisposition({
|
|
||||||
agentId: this.defaultAgentId,
|
|
||||||
ucid: body.ucid,
|
|
||||||
disposition: ozonetelDisposition,
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
const message = error.response?.data?.message ?? error.message ?? 'Disposition failed';
|
|
||||||
this.logger.error(`Dispose failed: ${message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle missed call callback status update
|
|
||||||
if (body.missedCallId) {
|
|
||||||
const statusMap: Record<string, string> = {
|
|
||||||
APPOINTMENT_BOOKED: 'CALLBACK_COMPLETED',
|
|
||||||
INFO_PROVIDED: 'CALLBACK_COMPLETED',
|
|
||||||
FOLLOW_UP_SCHEDULED: 'CALLBACK_COMPLETED',
|
|
||||||
CALLBACK_REQUESTED: 'CALLBACK_COMPLETED',
|
|
||||||
WRONG_NUMBER: 'WRONG_NUMBER',
|
|
||||||
};
|
|
||||||
const newStatus = statusMap[body.disposition];
|
|
||||||
if (newStatus) {
|
|
||||||
try {
|
|
||||||
await this.platform.query<any>(
|
|
||||||
`mutation { updateCall(id: "${body.missedCallId}", data: { callbackstatus: ${newStatus} }) { id } }`,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(`Failed to update missed call status: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-assign next missed call to this agent
|
|
||||||
try {
|
|
||||||
await this.missedQueue.assignNext(this.defaultAgentId);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Auto-assignment after dispose failed: ${err}`);
|
this.logger.warn(`Failed to update missed call status: ${err}`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return { status: 'ok' };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('dial')
|
// Auto-assign next missed call to this agent
|
||||||
async dial(
|
try {
|
||||||
@Body() body: { phoneNumber: string; campaignName?: string; leadId?: string },
|
await this.missedQueue.assignNext(this.defaultAgentId);
|
||||||
) {
|
} catch (err) {
|
||||||
if (!body.phoneNumber) {
|
this.logger.warn(`Auto-assignment after dispose failed: ${err}`);
|
||||||
throw new HttpException('phoneNumber required', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const campaignName = body.campaignName ?? process.env.OZONETEL_CAMPAIGN_NAME ?? 'Inbound_918041763265';
|
|
||||||
|
|
||||||
this.logger.log(`Manual dial: ${body.phoneNumber} campaign=${campaignName} (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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('call-control')
|
return { status: 'ok' };
|
||||||
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(`Call control: ${body.action} ucid=${body.ucid}`);
|
@Post('dial')
|
||||||
|
async dial(
|
||||||
try {
|
@Body()
|
||||||
const result = await this.ozonetelAgent.callControl(body);
|
body: {
|
||||||
return result;
|
phoneNumber: string;
|
||||||
} catch (error: any) {
|
campaignName?: string;
|
||||||
const message = error.response?.data?.message ?? error.message ?? 'Call control failed';
|
leadId?: string;
|
||||||
throw new HttpException(message, error.response?.status ?? 502);
|
},
|
||||||
}
|
) {
|
||||||
|
if (!body.phoneNumber) {
|
||||||
|
throw new HttpException('phoneNumber required', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('recording')
|
const campaignName =
|
||||||
async recording(
|
body.campaignName ??
|
||||||
@Body() body: { ucid: string; action: 'pause' | 'unPause' },
|
process.env.OZONETEL_CAMPAIGN_NAME ??
|
||||||
) {
|
'Inbound_918041763265';
|
||||||
if (!body.ucid || !body.action) {
|
|
||||||
throw new HttpException('ucid and action required', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
this.logger.log(
|
||||||
const result = await this.ozonetelAgent.pauseRecording(body);
|
`Manual dial: ${body.phoneNumber} campaign=${campaignName} (lead: ${body.leadId ?? 'none'})`,
|
||||||
return result;
|
);
|
||||||
} catch (error: any) {
|
|
||||||
const message = error.response?.data?.message ?? error.message ?? 'Recording control failed';
|
try {
|
||||||
throw new HttpException(message, error.response?.status ?? 502);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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')
|
this.logger.log(`Call control: ${body.action} ucid=${body.ucid}`);
|
||||||
async missedCalls() {
|
|
||||||
const result = await this.ozonetelAgent.getAbandonCalls();
|
try {
|
||||||
return result;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('recording')
|
||||||
|
async recording(@Body() body: { ucid: string; action: 'pause' | 'unPause' }) {
|
||||||
|
if (!body.ucid || !body.action) {
|
||||||
|
throw new HttpException('ucid and action required', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('call-history')
|
try {
|
||||||
async callHistory(
|
const result = await this.ozonetelAgent.pauseRecording(body);
|
||||||
@Query('date') date?: string,
|
return result;
|
||||||
@Query('status') status?: string,
|
} catch (error: any) {
|
||||||
@Query('callType') callType?: string,
|
const message =
|
||||||
) {
|
error.response?.data?.message ??
|
||||||
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
error.message ??
|
||||||
this.logger.log(`Call history: date=${targetDate} status=${status ?? 'all'} type=${callType ?? 'all'}`);
|
'Recording control failed';
|
||||||
|
throw new HttpException(message, error.response?.status ?? 502);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await this.ozonetelAgent.fetchCDR({
|
@Get('missed-calls')
|
||||||
date: targetDate,
|
async missedCalls() {
|
||||||
status,
|
const result = await this.ozonetelAgent.getAbandonCalls();
|
||||||
callType,
|
return result;
|
||||||
});
|
}
|
||||||
return result;
|
|
||||||
|
@Get('call-history')
|
||||||
|
async callHistory(
|
||||||
|
@Query('date') date?: string,
|
||||||
|
@Query('status') status?: string,
|
||||||
|
@Query('callType') callType?: string,
|
||||||
|
) {
|
||||||
|
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
||||||
|
this.logger.log(
|
||||||
|
`Call history: date=${targetDate} status=${status ?? 'all'} type=${callType ?? 'all'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await this.ozonetelAgent.fetchCDR({
|
||||||
|
date: targetDate,
|
||||||
|
status,
|
||||||
|
callType,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('performance')
|
||||||
|
async performance(@Query('date') date?: string) {
|
||||||
|
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
||||||
|
this.logger.log(
|
||||||
|
`Performance: date=${targetDate} agent=${this.defaultAgentId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [cdr, summary, aht] = await Promise.all([
|
||||||
|
this.ozonetelAgent.fetchCDR({ date: targetDate }),
|
||||||
|
this.ozonetelAgent.getAgentSummary(this.defaultAgentId, targetDate),
|
||||||
|
this.ozonetelAgent.getAHT(this.defaultAgentId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalCalls = cdr.length;
|
||||||
|
const inbound = cdr.filter((c: any) => c.Type === 'InBound').length;
|
||||||
|
const outbound = cdr.filter(
|
||||||
|
(c: any) => c.Type === 'Manual' || c.Type === 'Progressive',
|
||||||
|
).length;
|
||||||
|
const answered = cdr.filter((c: any) => c.Status === 'Answered').length;
|
||||||
|
const missed = cdr.filter(
|
||||||
|
(c: any) => c.Status === 'Unanswered' || c.Status === 'NotAnswered',
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const talkTimes = cdr
|
||||||
|
.filter((c: any) => c.TalkTime && c.TalkTime !== '00:00:00')
|
||||||
|
.map((c: any) => {
|
||||||
|
const parts = c.TalkTime.split(':').map(Number);
|
||||||
|
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||||
|
});
|
||||||
|
const avgTalkTimeSec =
|
||||||
|
talkTimes.length > 0
|
||||||
|
? Math.round(
|
||||||
|
talkTimes.reduce((a: number, b: number) => a + b, 0) /
|
||||||
|
talkTimes.length,
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const dispositions: Record<string, number> = {};
|
||||||
|
for (const c of cdr) {
|
||||||
|
const d = (c as any).Disposition || 'No Disposition';
|
||||||
|
dispositions[d] = (dispositions[d] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('performance')
|
const appointmentsBooked = cdr.filter((c: any) =>
|
||||||
async performance(@Query('date') date?: string) {
|
c.Disposition?.toLowerCase().includes('appointment'),
|
||||||
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
).length;
|
||||||
this.logger.log(`Performance: date=${targetDate} agent=${this.defaultAgentId}`);
|
|
||||||
|
|
||||||
const [cdr, summary, aht] = await Promise.all([
|
return {
|
||||||
this.ozonetelAgent.fetchCDR({ date: targetDate }),
|
date: targetDate,
|
||||||
this.ozonetelAgent.getAgentSummary(this.defaultAgentId, targetDate),
|
calls: { total: totalCalls, inbound, outbound, answered, missed },
|
||||||
this.ozonetelAgent.getAHT(this.defaultAgentId),
|
avgTalkTimeSec,
|
||||||
]);
|
avgHandlingTime: aht,
|
||||||
|
conversionRate:
|
||||||
|
totalCalls > 0
|
||||||
|
? Math.round((appointmentsBooked / totalCalls) * 100)
|
||||||
|
: 0,
|
||||||
|
appointmentsBooked,
|
||||||
|
timeUtilization: summary,
|
||||||
|
dispositions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const totalCalls = cdr.length;
|
private mapToOzonetelDisposition(disposition: string): string {
|
||||||
const inbound = cdr.filter((c: any) => c.Type === 'InBound').length;
|
// Campaign only has 'General Enquiry' configured currently
|
||||||
const outbound = cdr.filter((c: any) => c.Type === 'Manual' || c.Type === 'Progressive').length;
|
const map: Record<string, string> = {
|
||||||
const answered = cdr.filter((c: any) => c.Status === 'Answered').length;
|
APPOINTMENT_BOOKED: 'General Enquiry',
|
||||||
const missed = cdr.filter((c: any) => c.Status === 'Unanswered' || c.Status === 'NotAnswered').length;
|
FOLLOW_UP_SCHEDULED: 'General Enquiry',
|
||||||
|
INFO_PROVIDED: 'General Enquiry',
|
||||||
const talkTimes = cdr
|
NO_ANSWER: 'General Enquiry',
|
||||||
.filter((c: any) => c.TalkTime && c.TalkTime !== '00:00:00')
|
WRONG_NUMBER: 'General Enquiry',
|
||||||
.map((c: any) => {
|
CALLBACK_REQUESTED: 'General Enquiry',
|
||||||
const parts = c.TalkTime.split(':').map(Number);
|
};
|
||||||
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
return map[disposition] ?? 'General Enquiry';
|
||||||
});
|
}
|
||||||
const avgTalkTimeSec = talkTimes.length > 0
|
|
||||||
? Math.round(talkTimes.reduce((a: number, b: number) => a + b, 0) / talkTimes.length)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const dispositions: Record<string, number> = {};
|
|
||||||
for (const c of cdr) {
|
|
||||||
const d = (c as any).Disposition || 'No Disposition';
|
|
||||||
dispositions[d] = (dispositions[d] ?? 0) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const appointmentsBooked = cdr.filter((c: any) =>
|
|
||||||
c.Disposition?.toLowerCase().includes('appointment'),
|
|
||||||
).length;
|
|
||||||
|
|
||||||
return {
|
|
||||||
date: targetDate,
|
|
||||||
calls: { total: totalCalls, inbound, outbound, answered, missed },
|
|
||||||
avgTalkTimeSec,
|
|
||||||
avgHandlingTime: aht,
|
|
||||||
conversionRate: totalCalls > 0 ? Math.round((appointmentsBooked / totalCalls) * 100) : 0,
|
|
||||||
appointmentsBooked,
|
|
||||||
timeUtilization: summary,
|
|
||||||
dispositions,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private mapToOzonetelDisposition(disposition: string): string {
|
|
||||||
// Campaign only has 'General Enquiry' configured currently
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
'APPOINTMENT_BOOKED': 'General Enquiry',
|
|
||||||
'FOLLOW_UP_SCHEDULED': 'General Enquiry',
|
|
||||||
'INFO_PROVIDED': 'General Enquiry',
|
|
||||||
'NO_ANSWER': 'General Enquiry',
|
|
||||||
'WRONG_NUMBER': 'General Enquiry',
|
|
||||||
'CALLBACK_REQUESTED': 'General Enquiry',
|
|
||||||
};
|
|
||||||
return map[disposition] ?? 'General Enquiry';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import { WorklistModule } from '../worklist/worklist.module';
|
|||||||
import { PlatformModule } from '../platform/platform.module';
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule, forwardRef(() => WorklistModule)],
|
imports: [PlatformModule, forwardRef(() => WorklistModule)],
|
||||||
controllers: [OzonetelAgentController, KookooIvrController],
|
controllers: [OzonetelAgentController, KookooIvrController],
|
||||||
providers: [OzonetelAgentService],
|
providers: [OzonetelAgentService],
|
||||||
exports: [OzonetelAgentService],
|
exports: [OzonetelAgentService],
|
||||||
})
|
})
|
||||||
export class OzonetelAgentModule {}
|
export class OzonetelAgentModule {}
|
||||||
|
|||||||
@@ -4,463 +4,530 @@ import axios from 'axios';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OzonetelAgentService {
|
export class OzonetelAgentService {
|
||||||
private readonly logger = new Logger(OzonetelAgentService.name);
|
private readonly logger = new Logger(OzonetelAgentService.name);
|
||||||
private readonly apiDomain: string;
|
private readonly apiDomain: string;
|
||||||
private readonly apiKey: string;
|
private readonly apiKey: string;
|
||||||
private readonly accountId: string;
|
private readonly accountId: string;
|
||||||
private cachedToken: string | null = null;
|
private cachedToken: string | null = null;
|
||||||
private tokenExpiry: number = 0;
|
private tokenExpiry: number = 0;
|
||||||
|
|
||||||
constructor(private config: ConfigService) {
|
constructor(private config: ConfigService) {
|
||||||
this.apiDomain = config.get<string>('exotel.subdomain') ?? 'in1-ccaas-api.ozonetel.com';
|
this.apiDomain =
|
||||||
this.apiKey = config.get<string>('exotel.apiKey') ?? '';
|
config.get<string>('exotel.subdomain') ?? 'in1-ccaas-api.ozonetel.com';
|
||||||
this.accountId = config.get<string>('exotel.accountSid') ?? '';
|
this.apiKey = config.get<string>('exotel.apiKey') ?? '';
|
||||||
|
this.accountId = config.get<string>('exotel.accountSid') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getToken(): Promise<string> {
|
||||||
|
if (this.cachedToken && Date.now() < this.tokenExpiry) {
|
||||||
|
return this.cachedToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getToken(): Promise<string> {
|
const url = `https://${this.apiDomain}/ca_apis/CAToken/generateToken`;
|
||||||
if (this.cachedToken && Date.now() < this.tokenExpiry) {
|
this.logger.log('Generating CloudAgent API token');
|
||||||
return this.cachedToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = `https://${this.apiDomain}/ca_apis/CAToken/generateToken`;
|
const response = await axios.post(
|
||||||
this.logger.log('Generating CloudAgent API token');
|
url,
|
||||||
|
{ userName: this.accountId },
|
||||||
|
{
|
||||||
|
headers: { apiKey: this.apiKey, 'Content-Type': 'application/json' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const response = await axios.post(url, { userName: this.accountId }, {
|
const data = response.data;
|
||||||
headers: { apiKey: this.apiKey, 'Content-Type': 'application/json' },
|
if (data.token) {
|
||||||
});
|
this.cachedToken = data.token;
|
||||||
|
this.tokenExpiry = Date.now() + 55 * 60 * 1000;
|
||||||
const data = response.data;
|
this.logger.log('CloudAgent token generated successfully');
|
||||||
if (data.token) {
|
return data.token;
|
||||||
this.cachedToken = data.token;
|
|
||||||
this.tokenExpiry = Date.now() + 55 * 60 * 1000;
|
|
||||||
this.logger.log('CloudAgent token generated successfully');
|
|
||||||
return data.token;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(data.message ?? 'Token generation failed');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async loginAgent(params: {
|
throw new Error(data.message ?? 'Token generation failed');
|
||||||
agentId: string;
|
}
|
||||||
password: string;
|
|
||||||
phoneNumber: string;
|
|
||||||
mode?: string;
|
|
||||||
}): Promise<{ status: string; message: string }> {
|
|
||||||
const url = `https://${this.apiDomain}/CAServices/AgentAuthenticationV2/index.php`;
|
|
||||||
|
|
||||||
this.logger.log(`Logging in agent ${params.agentId} with phone ${params.phoneNumber}`);
|
async loginAgent(params: {
|
||||||
|
agentId: string;
|
||||||
|
password: string;
|
||||||
|
phoneNumber: string;
|
||||||
|
mode?: string;
|
||||||
|
}): Promise<{ status: string; message: string }> {
|
||||||
|
const url = `https://${this.apiDomain}/CAServices/AgentAuthenticationV2/index.php`;
|
||||||
|
|
||||||
try {
|
this.logger.log(
|
||||||
const response = await axios.post(
|
`Logging in agent ${params.agentId} with phone ${params.phoneNumber}`,
|
||||||
url,
|
);
|
||||||
new URLSearchParams({
|
|
||||||
userName: this.accountId,
|
|
||||||
apiKey: this.apiKey,
|
|
||||||
phoneNumber: params.phoneNumber,
|
|
||||||
action: 'login',
|
|
||||||
mode: params.mode ?? 'blended',
|
|
||||||
state: 'Ready',
|
|
||||||
}).toString(),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
username: params.agentId,
|
|
||||||
password: params.password,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const data = response.data;
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
url,
|
||||||
|
new URLSearchParams({
|
||||||
|
userName: this.accountId,
|
||||||
|
apiKey: this.apiKey,
|
||||||
|
phoneNumber: params.phoneNumber,
|
||||||
|
action: 'login',
|
||||||
|
mode: params.mode ?? 'blended',
|
||||||
|
state: 'Ready',
|
||||||
|
}).toString(),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
username: params.agentId,
|
||||||
|
password: params.password,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// "already logged in" is not a real error — treat as success
|
const data = response.data;
|
||||||
if (data.status === 'error' && data.message?.includes('already logged in')) {
|
|
||||||
this.logger.log(`Agent ${params.agentId} already logged in — treating as success`);
|
|
||||||
return { status: 'success', message: data.message };
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Agent login response: ${JSON.stringify(data)}`);
|
// "already logged in" is not a real error — treat as success
|
||||||
return data;
|
if (
|
||||||
} catch (error: any) {
|
data.status === 'error' &&
|
||||||
this.logger.error(`Agent login failed: ${error.message}`);
|
data.message?.includes('already logged in')
|
||||||
throw error;
|
) {
|
||||||
}
|
this.logger.log(
|
||||||
|
`Agent ${params.agentId} already logged in — treating as success`,
|
||||||
|
);
|
||||||
|
return { status: 'success', message: data.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Agent login response: ${JSON.stringify(data)}`);
|
||||||
|
return data;
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`Agent login failed: ${error.message}`);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async manualDial(params: {
|
async manualDial(params: {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
campaignName: string;
|
campaignName: string;
|
||||||
customerNumber: string;
|
customerNumber: string;
|
||||||
}): Promise<{ status: string; ucid?: string; message?: string }> {
|
}): Promise<{ status: string; ucid?: string; message?: string }> {
|
||||||
const url = `https://${this.apiDomain}/ca_apis/AgentManualDial`;
|
const url = `https://${this.apiDomain}/ca_apis/AgentManualDial`;
|
||||||
|
|
||||||
this.logger.log(`Manual dial: agent=${params.agentId} campaign=${params.campaignName} number=${params.customerNumber}`);
|
this.logger.log(
|
||||||
|
`Manual dial: agent=${params.agentId} campaign=${params.campaignName} number=${params.customerNumber}`,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = await this.getToken();
|
const token = await this.getToken();
|
||||||
const response = await axios.post(url, {
|
const response = await axios.post(
|
||||||
userName: this.accountId,
|
url,
|
||||||
agentID: params.agentId,
|
{
|
||||||
campaignName: params.campaignName,
|
userName: this.accountId,
|
||||||
customerNumber: params.customerNumber,
|
agentID: params.agentId,
|
||||||
UCID: 'true',
|
campaignName: params.campaignName,
|
||||||
}, {
|
customerNumber: params.customerNumber,
|
||||||
headers: {
|
UCID: 'true',
|
||||||
Authorization: `Bearer ${token}`,
|
},
|
||||||
'Content-Type': 'application/json',
|
{
|
||||||
},
|
headers: {
|
||||||
});
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
this.logger.log(`Manual dial response: ${JSON.stringify(response.data)}`);
|
this.logger.log(`Manual dial response: ${JSON.stringify(response.data)}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
|
const responseData = error?.response?.data
|
||||||
this.logger.error(`Manual dial failed: ${error.message} ${responseData}`);
|
? JSON.stringify(error.response.data)
|
||||||
throw error;
|
: '';
|
||||||
}
|
this.logger.error(`Manual dial failed: ${error.message} ${responseData}`);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async changeAgentState(params: {
|
async changeAgentState(params: {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
state: 'Ready' | 'Pause';
|
state: 'Ready' | 'Pause';
|
||||||
pauseReason?: string;
|
pauseReason?: string;
|
||||||
}): Promise<{ status: string; message: string }> {
|
}): Promise<{ status: string; message: string }> {
|
||||||
const url = `https://${this.apiDomain}/ca_apis/changeAgentState`;
|
const url = `https://${this.apiDomain}/ca_apis/changeAgentState`;
|
||||||
|
|
||||||
this.logger.log(`Changing agent ${params.agentId} state to ${params.state}`);
|
this.logger.log(
|
||||||
|
`Changing agent ${params.agentId} state to ${params.state}`,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body: Record<string, string> = {
|
const body: Record<string, string> = {
|
||||||
userName: this.accountId,
|
userName: this.accountId,
|
||||||
agentId: params.agentId,
|
agentId: params.agentId,
|
||||||
state: params.state,
|
state: params.state,
|
||||||
};
|
};
|
||||||
if (params.pauseReason) {
|
if (params.pauseReason) {
|
||||||
body.pauseReason = params.pauseReason;
|
body.pauseReason = params.pauseReason;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = await this.getToken();
|
const token = await this.getToken();
|
||||||
const response = await axios.post(url, body, {
|
const response = await axios.post(url, body, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`Change agent state response: ${JSON.stringify(response.data)}`);
|
this.logger.log(
|
||||||
return response.data;
|
`Change agent state response: ${JSON.stringify(response.data)}`,
|
||||||
} catch (error: any) {
|
);
|
||||||
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
|
return response.data;
|
||||||
this.logger.error(`Change agent state failed: ${error.message} ${responseData}`);
|
} catch (error: any) {
|
||||||
throw error;
|
const responseData = error?.response?.data
|
||||||
}
|
? JSON.stringify(error.response.data)
|
||||||
|
: '';
|
||||||
|
this.logger.error(
|
||||||
|
`Change agent state failed: ${error.message} ${responseData}`,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async setDisposition(params: {
|
async setDisposition(params: {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
ucid: string;
|
ucid: string;
|
||||||
disposition: string;
|
disposition: string;
|
||||||
}): Promise<{ status: string; message?: string; details?: string }> {
|
}): Promise<{ status: string; message?: string; details?: string }> {
|
||||||
const url = `https://${this.apiDomain}/ca_apis/DispositionAPIV2`;
|
const url = `https://${this.apiDomain}/ca_apis/DispositionAPIV2`;
|
||||||
const did = process.env.OZONETEL_DID ?? '918041763265';
|
const did = process.env.OZONETEL_DID ?? '918041763265';
|
||||||
|
|
||||||
this.logger.log(`Set disposition: agent=${params.agentId} ucid=${params.ucid} disposition=${params.disposition}`);
|
this.logger.log(
|
||||||
|
`Set disposition: agent=${params.agentId} ucid=${params.ucid} disposition=${params.disposition}`,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = await this.getToken();
|
const token = await this.getToken();
|
||||||
const response = await axios.post(url, {
|
const response = await axios.post(
|
||||||
userName: this.accountId,
|
url,
|
||||||
agentID: params.agentId,
|
{
|
||||||
did,
|
userName: this.accountId,
|
||||||
ucid: params.ucid,
|
agentID: params.agentId,
|
||||||
action: 'Set',
|
did,
|
||||||
disposition: params.disposition,
|
ucid: params.ucid,
|
||||||
autoRelease: 'true',
|
action: 'Set',
|
||||||
}, {
|
disposition: params.disposition,
|
||||||
headers: {
|
autoRelease: 'true',
|
||||||
Authorization: `Bearer ${token}`,
|
},
|
||||||
'Content-Type': 'application/json',
|
{
|
||||||
},
|
headers: {
|
||||||
});
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
this.logger.log(`Set disposition response: ${JSON.stringify(response.data)}`);
|
this.logger.log(
|
||||||
return response.data;
|
`Set disposition response: ${JSON.stringify(response.data)}`,
|
||||||
} catch (error: any) {
|
);
|
||||||
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
|
return response.data;
|
||||||
this.logger.error(`Set disposition failed: ${error.message} ${responseData}`);
|
} catch (error: any) {
|
||||||
throw error;
|
const responseData = error?.response?.data
|
||||||
}
|
? JSON.stringify(error.response.data)
|
||||||
|
: '';
|
||||||
|
this.logger.error(
|
||||||
|
`Set disposition failed: ${error.message} ${responseData}`,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async callControl(params: {
|
async callControl(params: {
|
||||||
action: 'CONFERENCE' | 'HOLD' | 'UNHOLD' | 'MUTE' | 'UNMUTE' | 'KICK_CALL';
|
action: 'CONFERENCE' | 'HOLD' | 'UNHOLD' | 'MUTE' | 'UNMUTE' | 'KICK_CALL';
|
||||||
ucid: string;
|
ucid: string;
|
||||||
conferenceNumber?: string;
|
conferenceNumber?: string;
|
||||||
}): Promise<{ status: string; message: string; ucid?: string }> {
|
}): Promise<{ status: string; message: string; ucid?: string }> {
|
||||||
const url = `https://${this.apiDomain}/ca_apis/CallControl_V4`;
|
const url = `https://${this.apiDomain}/ca_apis/CallControl_V4`;
|
||||||
const did = process.env.OZONETEL_DID ?? '918041763265';
|
const did = process.env.OZONETEL_DID ?? '918041763265';
|
||||||
const agentPhoneName = process.env.OZONETEL_SIP_ID ?? '523590';
|
const agentPhoneName = process.env.OZONETEL_SIP_ID ?? '523590';
|
||||||
|
|
||||||
this.logger.log(`Call control: action=${params.action} ucid=${params.ucid} conference=${params.conferenceNumber ?? 'none'}`);
|
this.logger.log(
|
||||||
|
`Call control: action=${params.action} ucid=${params.ucid} conference=${params.conferenceNumber ?? 'none'}`,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = await this.getToken();
|
const token = await this.getToken();
|
||||||
const body: Record<string, string> = {
|
const body: Record<string, string> = {
|
||||||
userName: this.accountId,
|
userName: this.accountId,
|
||||||
action: params.action,
|
action: params.action,
|
||||||
ucid: params.ucid,
|
ucid: params.ucid,
|
||||||
did,
|
did,
|
||||||
agentPhoneName,
|
agentPhoneName,
|
||||||
};
|
};
|
||||||
if (params.conferenceNumber) {
|
if (params.conferenceNumber) {
|
||||||
body.conferenceNumber = params.conferenceNumber;
|
body.conferenceNumber = params.conferenceNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await axios.post(url, body, {
|
const response = await axios.post(url, body, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`Call control response: ${JSON.stringify(response.data)}`);
|
this.logger.log(
|
||||||
return response.data;
|
`Call control response: ${JSON.stringify(response.data)}`,
|
||||||
} catch (error: any) {
|
);
|
||||||
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
|
return response.data;
|
||||||
this.logger.error(`Call control failed: ${error.message} ${responseData}`);
|
} catch (error: any) {
|
||||||
throw error;
|
const responseData = error?.response?.data
|
||||||
}
|
? JSON.stringify(error.response.data)
|
||||||
|
: '';
|
||||||
|
this.logger.error(
|
||||||
|
`Call control failed: ${error.message} ${responseData}`,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async pauseRecording(params: {
|
async pauseRecording(params: {
|
||||||
ucid: string;
|
ucid: string;
|
||||||
action: 'pause' | 'unPause';
|
action: 'pause' | 'unPause';
|
||||||
}): Promise<{ status: string; message: string }> {
|
}): Promise<{ status: string; message: string }> {
|
||||||
const url = `https://${this.apiDomain}/CAServices/Call/Record.php`;
|
const url = `https://${this.apiDomain}/CAServices/Call/Record.php`;
|
||||||
|
|
||||||
this.logger.log(`Recording ${params.action}: ucid=${params.ucid}`);
|
this.logger.log(`Recording ${params.action}: ucid=${params.ucid}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(url, {
|
const response = await axios.get(url, {
|
||||||
params: {
|
params: {
|
||||||
userName: this.accountId,
|
userName: this.accountId,
|
||||||
apiKey: this.apiKey,
|
apiKey: this.apiKey,
|
||||||
action: params.action,
|
action: params.action,
|
||||||
ucid: params.ucid,
|
ucid: params.ucid,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`Recording control response: ${JSON.stringify(response.data)}`);
|
this.logger.log(
|
||||||
return response.data;
|
`Recording control response: ${JSON.stringify(response.data)}`,
|
||||||
} catch (error: any) {
|
);
|
||||||
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
|
return response.data;
|
||||||
this.logger.error(`Recording control failed: ${error.message} ${responseData}`);
|
} catch (error: any) {
|
||||||
throw error;
|
const responseData = error?.response?.data
|
||||||
}
|
? JSON.stringify(error.response.data)
|
||||||
|
: '';
|
||||||
|
this.logger.error(
|
||||||
|
`Recording control failed: ${error.message} ${responseData}`,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getAbandonCalls(params?: {
|
async getAbandonCalls(params?: {
|
||||||
fromTime?: string;
|
fromTime?: string;
|
||||||
toTime?: string;
|
toTime?: string;
|
||||||
campaignName?: string;
|
campaignName?: string;
|
||||||
}): Promise<Array<{
|
}): Promise<
|
||||||
monitorUCID: string;
|
Array<{
|
||||||
type: string;
|
monitorUCID: string;
|
||||||
status: string;
|
type: string;
|
||||||
campaign: string;
|
status: string;
|
||||||
callerID: string;
|
campaign: string;
|
||||||
did: string;
|
callerID: string;
|
||||||
agentID: string;
|
did: string;
|
||||||
agent: string;
|
agentID: string;
|
||||||
hangupBy: string;
|
agent: string;
|
||||||
callTime: string;
|
hangupBy: string;
|
||||||
}>> {
|
callTime: string;
|
||||||
const url = `https://${this.apiDomain}/ca_apis/abandonCalls`;
|
}>
|
||||||
|
> {
|
||||||
|
const url = `https://${this.apiDomain}/ca_apis/abandonCalls`;
|
||||||
|
|
||||||
this.logger.log('Fetching abandon calls');
|
this.logger.log('Fetching abandon calls');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = await this.getToken();
|
const token = await this.getToken();
|
||||||
const body: Record<string, string> = { userName: this.accountId };
|
const body: Record<string, string> = { userName: this.accountId };
|
||||||
if (params?.fromTime) body.fromTime = params.fromTime;
|
if (params?.fromTime) body.fromTime = params.fromTime;
|
||||||
if (params?.toTime) body.toTime = params.toTime;
|
if (params?.toTime) body.toTime = params.toTime;
|
||||||
if (params?.campaignName) body.campaignName = params.campaignName;
|
if (params?.campaignName) body.campaignName = params.campaignName;
|
||||||
|
|
||||||
const response = await axios({
|
const response = await axios({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url,
|
url,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
data: JSON.stringify(body),
|
data: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
this.logger.log(`Abandon calls response: ${JSON.stringify(data).substring(0, 200)}`);
|
this.logger.log(
|
||||||
if (data.status === 'success' && Array.isArray(data.message)) {
|
`Abandon calls response: ${JSON.stringify(data).substring(0, 200)}`,
|
||||||
return data.message;
|
);
|
||||||
}
|
if (data.status === 'success' && Array.isArray(data.message)) {
|
||||||
return [];
|
return data.message;
|
||||||
} catch (error: any) {
|
}
|
||||||
this.logger.error(`Abandon calls failed: ${error.message}`);
|
return [];
|
||||||
return [];
|
} catch (error: any) {
|
||||||
}
|
this.logger.error(`Abandon calls failed: ${error.message}`);
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fetchCDR(params: {
|
async fetchCDR(params: {
|
||||||
date: string; // YYYY-MM-DD
|
date: string; // YYYY-MM-DD
|
||||||
campaignName?: string;
|
campaignName?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
callType?: string;
|
callType?: string;
|
||||||
}): Promise<Array<Record<string, any>>> {
|
}): Promise<Array<Record<string, any>>> {
|
||||||
const url = `https://${this.apiDomain}/ca_reports/fetchCDRDetails`;
|
const url = `https://${this.apiDomain}/ca_reports/fetchCDRDetails`;
|
||||||
|
|
||||||
this.logger.log(`Fetch CDR: date=${params.date}`);
|
this.logger.log(`Fetch CDR: date=${params.date}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = await this.getToken();
|
const token = await this.getToken();
|
||||||
const body: Record<string, string> = {
|
const body: Record<string, string> = {
|
||||||
userName: this.accountId,
|
userName: this.accountId,
|
||||||
fromDate: `${params.date} 00:00:00`,
|
fromDate: `${params.date} 00:00:00`,
|
||||||
toDate: `${params.date} 23:59:59`,
|
toDate: `${params.date} 23:59:59`,
|
||||||
};
|
};
|
||||||
if (params.campaignName) body.campaignName = params.campaignName;
|
if (params.campaignName) body.campaignName = params.campaignName;
|
||||||
if (params.status) body.status = params.status;
|
if (params.status) body.status = params.status;
|
||||||
if (params.callType) body.callType = params.callType;
|
if (params.callType) body.callType = params.callType;
|
||||||
|
|
||||||
const response = await axios({
|
const response = await axios({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url,
|
url,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
data: JSON.stringify(body),
|
data: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
if (data.status === 'success' && Array.isArray(data.details)) {
|
if (data.status === 'success' && Array.isArray(data.details)) {
|
||||||
return data.details;
|
return data.details;
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
|
const responseData = error?.response?.data
|
||||||
this.logger.error(`Fetch CDR failed: ${error.message} ${responseData}`);
|
? JSON.stringify(error.response.data)
|
||||||
return [];
|
: '';
|
||||||
}
|
this.logger.error(`Fetch CDR failed: ${error.message} ${responseData}`);
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getAgentSummary(agentId: string, date: string): Promise<{
|
async getAgentSummary(
|
||||||
totalLoginDuration: string;
|
agentId: string,
|
||||||
totalBusyTime: string;
|
date: string,
|
||||||
totalIdleTime: string;
|
): Promise<{
|
||||||
totalPauseTime: string;
|
totalLoginDuration: string;
|
||||||
totalWrapupTime: string;
|
totalBusyTime: string;
|
||||||
totalDialTime: string;
|
totalIdleTime: string;
|
||||||
} | null> {
|
totalPauseTime: string;
|
||||||
const url = `https://${this.apiDomain}/ca_reports/summaryReport`;
|
totalWrapupTime: string;
|
||||||
|
totalDialTime: string;
|
||||||
|
} | null> {
|
||||||
|
const url = `https://${this.apiDomain}/ca_reports/summaryReport`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = await this.getToken();
|
const token = await this.getToken();
|
||||||
const response = await axios({
|
const response = await axios({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url,
|
url,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
data: JSON.stringify({
|
data: JSON.stringify({
|
||||||
userName: this.accountId,
|
userName: this.accountId,
|
||||||
agentId,
|
agentId,
|
||||||
fromDate: `${date} 00:00:00`,
|
fromDate: `${date} 00:00:00`,
|
||||||
toDate: `${date} 23:59:59`,
|
toDate: `${date} 23:59:59`,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
if (data.status === 'success' && data.message) {
|
if (data.status === 'success' && data.message) {
|
||||||
const record = Array.isArray(data.message) ? data.message[0] : data.message;
|
const record = Array.isArray(data.message)
|
||||||
return {
|
? data.message[0]
|
||||||
totalLoginDuration: record.TotalLoginDuration ?? '00:00:00',
|
: data.message;
|
||||||
totalBusyTime: record.TotalBusyTime ?? '00:00:00',
|
return {
|
||||||
totalIdleTime: record.TotalIdleTime ?? '00:00:00',
|
totalLoginDuration: record.TotalLoginDuration ?? '00:00:00',
|
||||||
totalPauseTime: record.TotalPauseTime ?? '00:00:00',
|
totalBusyTime: record.TotalBusyTime ?? '00:00:00',
|
||||||
totalWrapupTime: record.TotalWrapupTime ?? '00:00:00',
|
totalIdleTime: record.TotalIdleTime ?? '00:00:00',
|
||||||
totalDialTime: record.TotalDialTime ?? '00:00:00',
|
totalPauseTime: record.TotalPauseTime ?? '00:00:00',
|
||||||
};
|
totalWrapupTime: record.TotalWrapupTime ?? '00:00:00',
|
||||||
}
|
totalDialTime: record.TotalDialTime ?? '00:00:00',
|
||||||
return null;
|
};
|
||||||
} catch (error: any) {
|
}
|
||||||
this.logger.error(`Agent summary failed: ${error.message}`);
|
return null;
|
||||||
return null;
|
} catch (error: any) {
|
||||||
}
|
this.logger.error(`Agent summary failed: ${error.message}`);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getAHT(agentId: string): Promise<string> {
|
async getAHT(agentId: string): Promise<string> {
|
||||||
const url = `https://${this.apiDomain}/ca_apis/aht`;
|
const url = `https://${this.apiDomain}/ca_apis/aht`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = await this.getToken();
|
const token = await this.getToken();
|
||||||
const response = await axios({
|
const response = await axios({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url,
|
url,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
data: JSON.stringify({
|
data: JSON.stringify({
|
||||||
userName: this.accountId,
|
userName: this.accountId,
|
||||||
agentId,
|
agentId,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
if (data.status === 'success') {
|
if (data.status === 'success') {
|
||||||
return data.AHT ?? '00:00:00';
|
return data.AHT ?? '00:00:00';
|
||||||
}
|
}
|
||||||
return '00:00:00';
|
return '00:00:00';
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`AHT failed: ${error.message}`);
|
this.logger.error(`AHT failed: ${error.message}`);
|
||||||
return '00:00:00';
|
return '00:00:00';
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async logoutAgent(params: {
|
async logoutAgent(params: {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
password: string;
|
password: string;
|
||||||
}): Promise<{ status: string; message: string }> {
|
}): Promise<{ status: string; message: string }> {
|
||||||
const url = `https://${this.apiDomain}/CAServices/AgentAuthenticationV2/index.php`;
|
const url = `https://${this.apiDomain}/CAServices/AgentAuthenticationV2/index.php`;
|
||||||
|
|
||||||
this.logger.log(`Logging out agent ${params.agentId}`);
|
this.logger.log(`Logging out agent ${params.agentId}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
url,
|
url,
|
||||||
new URLSearchParams({
|
new URLSearchParams({
|
||||||
userName: this.accountId,
|
userName: this.accountId,
|
||||||
apiKey: this.apiKey,
|
apiKey: this.apiKey,
|
||||||
action: 'logout',
|
action: 'logout',
|
||||||
mode: 'blended',
|
mode: 'blended',
|
||||||
state: 'Ready',
|
state: 'Ready',
|
||||||
}).toString(),
|
}).toString(),
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
username: params.agentId,
|
username: params.agentId,
|
||||||
password: params.password,
|
password: params.password,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(`Agent logout response: ${JSON.stringify(response.data)}`);
|
this.logger.log(
|
||||||
return response.data;
|
`Agent logout response: ${JSON.stringify(response.data)}`,
|
||||||
} catch (error: any) {
|
);
|
||||||
this.logger.error(`Agent logout failed: ${error.message}`);
|
return response.data;
|
||||||
throw error;
|
} catch (error: any) {
|
||||||
}
|
this.logger.error(`Agent logout failed: ${error.message}`);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +1,58 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type { LeadNode, LeadActivityNode, CreateCallInput, CreateLeadActivityInput, UpdateLeadInput } from './platform.types';
|
import type {
|
||||||
|
LeadNode,
|
||||||
|
LeadActivityNode,
|
||||||
|
CreateCallInput,
|
||||||
|
CreateLeadActivityInput,
|
||||||
|
UpdateLeadInput,
|
||||||
|
} from './platform.types';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PlatformGraphqlService {
|
export class PlatformGraphqlService {
|
||||||
private readonly graphqlUrl: string;
|
private readonly graphqlUrl: string;
|
||||||
private readonly apiKey: string;
|
private readonly apiKey: string;
|
||||||
|
|
||||||
constructor(private config: ConfigService) {
|
constructor(private config: ConfigService) {
|
||||||
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
|
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
|
||||||
this.apiKey = config.get<string>('platform.apiKey')!;
|
this.apiKey = config.get<string>('platform.apiKey')!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server-to-server query using API key
|
||||||
|
async query<T>(query: string, variables?: Record<string, any>): Promise<T> {
|
||||||
|
return this.queryWithAuth<T>(query, variables, `Bearer ${this.apiKey}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query using a passed-through auth header (user JWT)
|
||||||
|
async queryWithAuth<T>(
|
||||||
|
query: string,
|
||||||
|
variables: Record<string, any> | undefined,
|
||||||
|
authHeader: string,
|
||||||
|
): Promise<T> {
|
||||||
|
const response = await axios.post(
|
||||||
|
this.graphqlUrl,
|
||||||
|
{ query, variables },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: authHeader,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data.errors) {
|
||||||
|
throw new Error(`GraphQL error: ${JSON.stringify(response.data.errors)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server-to-server query using API key
|
return response.data.data;
|
||||||
async query<T>(query: string, variables?: Record<string, any>): Promise<T> {
|
}
|
||||||
return this.queryWithAuth<T>(query, variables, `Bearer ${this.apiKey}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Query using a passed-through auth header (user JWT)
|
async findLeadByPhone(phone: string): Promise<LeadNode | null> {
|
||||||
async queryWithAuth<T>(query: string, variables: Record<string, any> | undefined, authHeader: string): Promise<T> {
|
// Note: The exact filter syntax for PHONES fields depends on the platform
|
||||||
const response = await axios.post(
|
// This queries leads and filters client-side by phone number
|
||||||
this.graphqlUrl,
|
const data = await this.query<{ leads: { edges: { node: LeadNode }[] } }>(
|
||||||
{ query, variables },
|
`query FindLeads($first: Int) {
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': authHeader,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.data.errors) {
|
|
||||||
throw new Error(`GraphQL error: ${JSON.stringify(response.data.errors)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async findLeadByPhone(phone: string): Promise<LeadNode | null> {
|
|
||||||
// Note: The exact filter syntax for PHONES fields depends on the platform
|
|
||||||
// This queries leads and filters client-side by phone number
|
|
||||||
const data = await this.query<{ leads: { edges: { node: LeadNode }[] } }>(
|
|
||||||
`query FindLeads($first: Int) {
|
|
||||||
leads(first: $first, orderBy: { createdAt: DescNullsLast }) {
|
leads(first: $first, orderBy: { createdAt: DescNullsLast }) {
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
@@ -58,20 +68,26 @@ export class PlatformGraphqlService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
{ first: 100 },
|
{ first: 100 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Client-side phone matching (strip non-digits for comparison)
|
||||||
|
const normalizedPhone = phone.replace(/\D/g, '');
|
||||||
|
return (
|
||||||
|
data.leads.edges.find((edge) => {
|
||||||
|
const leadPhones = edge.node.contactPhone ?? [];
|
||||||
|
return leadPhones.some(
|
||||||
|
(p) =>
|
||||||
|
p.number.replace(/\D/g, '').endsWith(normalizedPhone) ||
|
||||||
|
normalizedPhone.endsWith(p.number.replace(/\D/g, '')),
|
||||||
);
|
);
|
||||||
|
})?.node ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Client-side phone matching (strip non-digits for comparison)
|
async findLeadById(id: string): Promise<LeadNode | null> {
|
||||||
const normalizedPhone = phone.replace(/\D/g, '');
|
const data = await this.query<{ lead: LeadNode }>(
|
||||||
return data.leads.edges.find(edge => {
|
`query FindLead($id: ID!) {
|
||||||
const leadPhones = edge.node.contactPhone ?? [];
|
|
||||||
return leadPhones.some(p => p.number.replace(/\D/g, '').endsWith(normalizedPhone) || normalizedPhone.endsWith(p.number.replace(/\D/g, '')));
|
|
||||||
})?.node ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async findLeadById(id: string): Promise<LeadNode | null> {
|
|
||||||
const data = await this.query<{ lead: LeadNode }>(
|
|
||||||
`query FindLead($id: ID!) {
|
|
||||||
lead(id: $id) {
|
lead(id: $id) {
|
||||||
id createdAt
|
id createdAt
|
||||||
contactName { firstName lastName }
|
contactName { firstName lastName }
|
||||||
@@ -83,51 +99,58 @@ export class PlatformGraphqlService {
|
|||||||
aiSummary aiSuggestedAction
|
aiSummary aiSuggestedAction
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
{ id },
|
{ id },
|
||||||
);
|
);
|
||||||
return data.lead;
|
return data.lead;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateLead(id: string, input: UpdateLeadInput): Promise<LeadNode> {
|
async updateLead(id: string, input: UpdateLeadInput): Promise<LeadNode> {
|
||||||
const data = await this.query<{ updateLead: LeadNode }>(
|
const data = await this.query<{ updateLead: LeadNode }>(
|
||||||
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
|
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
|
||||||
updateLead(id: $id, data: $data) {
|
updateLead(id: $id, data: $data) {
|
||||||
id leadStatus aiSummary aiSuggestedAction
|
id leadStatus aiSummary aiSuggestedAction
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
{ id, data: input },
|
{ id, data: input },
|
||||||
);
|
);
|
||||||
return data.updateLead;
|
return data.updateLead;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createCall(input: CreateCallInput): Promise<{ id: string }> {
|
async createCall(input: CreateCallInput): Promise<{ id: string }> {
|
||||||
const data = await this.query<{ createCall: { id: string } }>(
|
const data = await this.query<{ createCall: { id: string } }>(
|
||||||
`mutation CreateCall($data: CallCreateInput!) {
|
`mutation CreateCall($data: CallCreateInput!) {
|
||||||
createCall(data: $data) { id }
|
createCall(data: $data) { id }
|
||||||
}`,
|
}`,
|
||||||
{ data: input },
|
{ data: input },
|
||||||
);
|
);
|
||||||
return data.createCall;
|
return data.createCall;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createLeadActivity(input: CreateLeadActivityInput): Promise<{ id: string }> {
|
async createLeadActivity(
|
||||||
const data = await this.query<{ createLeadActivity: { id: string } }>(
|
input: CreateLeadActivityInput,
|
||||||
`mutation CreateLeadActivity($data: LeadActivityCreateInput!) {
|
): Promise<{ id: string }> {
|
||||||
|
const data = await this.query<{ createLeadActivity: { id: string } }>(
|
||||||
|
`mutation CreateLeadActivity($data: LeadActivityCreateInput!) {
|
||||||
createLeadActivity(data: $data) { id }
|
createLeadActivity(data: $data) { id }
|
||||||
}`,
|
}`,
|
||||||
{ data: input },
|
{ data: input },
|
||||||
);
|
);
|
||||||
return data.createLeadActivity;
|
return data.createLeadActivity;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Token passthrough versions (for user-driven requests) ---
|
// --- Token passthrough versions (for user-driven requests) ---
|
||||||
|
|
||||||
async findLeadByPhoneWithToken(phone: string, authHeader: string): Promise<LeadNode | null> {
|
async findLeadByPhoneWithToken(
|
||||||
const normalizedPhone = phone.replace(/\D/g, '');
|
phone: string,
|
||||||
const last10 = normalizedPhone.slice(-10);
|
authHeader: string,
|
||||||
|
): Promise<LeadNode | null> {
|
||||||
|
const normalizedPhone = phone.replace(/\D/g, '');
|
||||||
|
const last10 = normalizedPhone.slice(-10);
|
||||||
|
|
||||||
const data = await this.queryWithAuth<{ leads: { edges: { node: LeadNode }[] } }>(
|
const data = await this.queryWithAuth<{
|
||||||
`query FindLeads($first: Int) {
|
leads: { edges: { node: LeadNode }[] };
|
||||||
|
}>(
|
||||||
|
`query FindLeads($first: Int) {
|
||||||
leads(first: $first, orderBy: [{ createdAt: DescNullsLast }]) {
|
leads(first: $first, orderBy: [{ createdAt: DescNullsLast }]) {
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
@@ -143,28 +166,43 @@ export class PlatformGraphqlService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
{ first: 200 },
|
{ first: 200 },
|
||||||
authHeader,
|
authHeader,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Client-side phone matching
|
// Client-side phone matching
|
||||||
return data.leads.edges.find(edge => {
|
return (
|
||||||
const phones = edge.node.contactPhone ?? [];
|
data.leads.edges.find((edge) => {
|
||||||
if (Array.isArray(phones)) {
|
const phones = edge.node.contactPhone ?? [];
|
||||||
return phones.some((p: any) => {
|
if (Array.isArray(phones)) {
|
||||||
const num = (p.number ?? p.primaryPhoneNumber ?? '').replace(/\D/g, '');
|
return phones.some((p: any) => {
|
||||||
return num.endsWith(last10) || last10.endsWith(num);
|
const num = (p.number ?? p.primaryPhoneNumber ?? '').replace(
|
||||||
});
|
/\D/g,
|
||||||
}
|
'',
|
||||||
// Handle single phone object
|
);
|
||||||
const num = ((phones as any).primaryPhoneNumber ?? (phones as any).number ?? '').replace(/\D/g, '');
|
|
||||||
return num.endsWith(last10) || last10.endsWith(num);
|
return num.endsWith(last10) || last10.endsWith(num);
|
||||||
})?.node ?? null;
|
});
|
||||||
}
|
}
|
||||||
|
// Handle single phone object
|
||||||
|
const num = (
|
||||||
|
(phones as any).primaryPhoneNumber ??
|
||||||
|
(phones as any).number ??
|
||||||
|
''
|
||||||
|
).replace(/\D/g, '');
|
||||||
|
return num.endsWith(last10) || last10.endsWith(num);
|
||||||
|
})?.node ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async getLeadActivitiesWithToken(leadId: string, authHeader: string, limit = 5): Promise<LeadActivityNode[]> {
|
async getLeadActivitiesWithToken(
|
||||||
const data = await this.queryWithAuth<{ leadActivities: { edges: { node: LeadActivityNode }[] } }>(
|
leadId: string,
|
||||||
`query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) {
|
authHeader: string,
|
||||||
|
limit = 5,
|
||||||
|
): Promise<LeadActivityNode[]> {
|
||||||
|
const data = await this.queryWithAuth<{
|
||||||
|
leadActivities: { edges: { node: LeadActivityNode }[] };
|
||||||
|
}>(
|
||||||
|
`query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) {
|
||||||
leadActivities(filter: $filter, first: $first, orderBy: [{ occurredAt: DescNullsLast }]) {
|
leadActivities(filter: $filter, first: $first, orderBy: [{ occurredAt: DescNullsLast }]) {
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
@@ -173,30 +211,39 @@ export class PlatformGraphqlService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
{ filter: { leadId: { eq: leadId } }, first: limit },
|
{ filter: { leadId: { eq: leadId } }, first: limit },
|
||||||
authHeader,
|
authHeader,
|
||||||
);
|
);
|
||||||
return data.leadActivities.edges.map(e => e.node);
|
return data.leadActivities.edges.map((e) => e.node);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateLeadWithToken(id: string, input: UpdateLeadInput, authHeader: string): Promise<LeadNode> {
|
async updateLeadWithToken(
|
||||||
const data = await this.queryWithAuth<{ updateLead: LeadNode }>(
|
id: string,
|
||||||
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
|
input: UpdateLeadInput,
|
||||||
|
authHeader: string,
|
||||||
|
): Promise<LeadNode> {
|
||||||
|
const data = await this.queryWithAuth<{ updateLead: LeadNode }>(
|
||||||
|
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
|
||||||
updateLead(id: $id, data: $data) {
|
updateLead(id: $id, data: $data) {
|
||||||
id leadStatus aiSummary aiSuggestedAction
|
id leadStatus aiSummary aiSuggestedAction
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
{ id, data: input },
|
{ id, data: input },
|
||||||
authHeader,
|
authHeader,
|
||||||
);
|
);
|
||||||
return data.updateLead;
|
return data.updateLead;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Server-to-server versions (for webhooks, background jobs) ---
|
// --- Server-to-server versions (for webhooks, background jobs) ---
|
||||||
|
|
||||||
async getLeadActivities(leadId: string, limit = 3): Promise<LeadActivityNode[]> {
|
async getLeadActivities(
|
||||||
const data = await this.query<{ leadActivities: { edges: { node: LeadActivityNode }[] } }>(
|
leadId: string,
|
||||||
`query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) {
|
limit = 3,
|
||||||
|
): Promise<LeadActivityNode[]> {
|
||||||
|
const data = await this.query<{
|
||||||
|
leadActivities: { edges: { node: LeadActivityNode }[] };
|
||||||
|
}>(
|
||||||
|
`query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) {
|
||||||
leadActivities(filter: $filter, first: $first, orderBy: { occurredAt: DescNullsLast }) {
|
leadActivities(filter: $filter, first: $first, orderBy: { occurredAt: DescNullsLast }) {
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
@@ -205,8 +252,8 @@ export class PlatformGraphqlService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
{ filter: { leadId: { eq: leadId } }, first: limit },
|
{ filter: { leadId: { eq: leadId } }, first: limit },
|
||||||
);
|
);
|
||||||
return data.leadActivities.edges.map(e => e.node);
|
return data.leadActivities.edges.map((e) => e.node);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
|
|||||||
import { PlatformGraphqlService } from './platform-graphql.service';
|
import { PlatformGraphqlService } from './platform-graphql.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [PlatformGraphqlService],
|
providers: [PlatformGraphqlService],
|
||||||
exports: [PlatformGraphqlService],
|
exports: [PlatformGraphqlService],
|
||||||
})
|
})
|
||||||
export class PlatformModule {}
|
export class PlatformModule {}
|
||||||
|
|||||||
@@ -1,71 +1,71 @@
|
|||||||
export type LeadNode = {
|
export type LeadNode = {
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
contactName: { firstName: string; lastName: string } | null;
|
contactName: { firstName: string; lastName: string } | null;
|
||||||
contactPhone: { number: string; callingCode: string }[] | null;
|
contactPhone: { number: string; callingCode: string }[] | null;
|
||||||
contactEmail: { address: string }[] | null;
|
contactEmail: { address: string }[] | null;
|
||||||
leadSource: string | null;
|
leadSource: string | null;
|
||||||
leadStatus: string | null;
|
leadStatus: string | null;
|
||||||
interestedService: string | null;
|
interestedService: string | null;
|
||||||
assignedAgent: string | null;
|
assignedAgent: string | null;
|
||||||
campaignId: string | null;
|
campaignId: string | null;
|
||||||
adId: string | null;
|
adId: string | null;
|
||||||
contactAttempts: number | null;
|
contactAttempts: number | null;
|
||||||
spamScore: number | null;
|
spamScore: number | null;
|
||||||
isSpam: boolean | null;
|
isSpam: boolean | null;
|
||||||
aiSummary: string | null;
|
aiSummary: string | null;
|
||||||
aiSuggestedAction: string | null;
|
aiSuggestedAction: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LeadActivityNode = {
|
export type LeadActivityNode = {
|
||||||
id: string;
|
id: string;
|
||||||
activityType: string | null;
|
activityType: string | null;
|
||||||
summary: string | null;
|
summary: string | null;
|
||||||
occurredAt: string | null;
|
occurredAt: string | null;
|
||||||
performedBy: string | null;
|
performedBy: string | null;
|
||||||
channel: string | null;
|
channel: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CallNode = {
|
export type CallNode = {
|
||||||
id: string;
|
id: string;
|
||||||
callDirection: string | null;
|
callDirection: string | null;
|
||||||
callStatus: string | null;
|
callStatus: string | null;
|
||||||
disposition: string | null;
|
disposition: string | null;
|
||||||
agentName: string | null;
|
agentName: string | null;
|
||||||
startedAt: string | null;
|
startedAt: string | null;
|
||||||
endedAt: string | null;
|
endedAt: string | null;
|
||||||
durationSeconds: number | null;
|
durationSeconds: number | null;
|
||||||
leadId: string | null;
|
leadId: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreateCallInput = {
|
export type CreateCallInput = {
|
||||||
callDirection: string;
|
callDirection: string;
|
||||||
callStatus: string;
|
callStatus: string;
|
||||||
callerNumber?: { number: string; callingCode: string }[];
|
callerNumber?: { number: string; callingCode: string }[];
|
||||||
agentName?: string;
|
agentName?: string;
|
||||||
startedAt?: string;
|
startedAt?: string;
|
||||||
endedAt?: string;
|
endedAt?: string;
|
||||||
durationSeconds?: number;
|
durationSeconds?: number;
|
||||||
disposition?: string;
|
disposition?: string;
|
||||||
callNotes?: string;
|
callNotes?: string;
|
||||||
leadId?: string;
|
leadId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreateLeadActivityInput = {
|
export type CreateLeadActivityInput = {
|
||||||
activityType: string;
|
activityType: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
occurredAt: string;
|
occurredAt: string;
|
||||||
performedBy: string;
|
performedBy: string;
|
||||||
channel: string;
|
channel: string;
|
||||||
durationSeconds?: number;
|
durationSeconds?: number;
|
||||||
outcome?: string;
|
outcome?: string;
|
||||||
leadId: string;
|
leadId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UpdateLeadInput = {
|
export type UpdateLeadInput = {
|
||||||
leadStatus?: string;
|
leadStatus?: string;
|
||||||
lastContactedAt?: string;
|
lastContactedAt?: string;
|
||||||
aiSummary?: string;
|
aiSummary?: string;
|
||||||
aiSuggestedAction?: string;
|
aiSuggestedAction?: string;
|
||||||
contactAttempts?: number;
|
contactAttempts?: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,91 +4,113 @@ import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
|||||||
|
|
||||||
@Controller('api/search')
|
@Controller('api/search')
|
||||||
export class SearchController {
|
export class SearchController {
|
||||||
private readonly logger = new Logger(SearchController.name);
|
private readonly logger = new Logger(SearchController.name);
|
||||||
private readonly platformApiKey: string;
|
private readonly platformApiKey: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly platform: PlatformGraphqlService,
|
private readonly platform: PlatformGraphqlService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
) {
|
) {
|
||||||
this.platformApiKey = config.get<string>('platform.apiKey') ?? '';
|
this.platformApiKey = config.get<string>('platform.apiKey') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async search(@Query('q') query?: string) {
|
||||||
|
if (!query || query.length < 2) {
|
||||||
|
return { leads: [], patients: [], appointments: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
const authHeader = this.platformApiKey
|
||||||
async search(@Query('q') query?: string) {
|
? `Bearer ${this.platformApiKey}`
|
||||||
if (!query || query.length < 2) {
|
: '';
|
||||||
return { leads: [], patients: [], appointments: [] };
|
if (!authHeader) {
|
||||||
}
|
return { leads: [], patients: [], appointments: [] };
|
||||||
|
}
|
||||||
|
|
||||||
const authHeader = this.platformApiKey ? `Bearer ${this.platformApiKey}` : '';
|
this.logger.log(`Search: "${query}"`);
|
||||||
if (!authHeader) {
|
|
||||||
return { leads: [], patients: [], appointments: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Search: "${query}"`);
|
// Fetch all three in parallel, filter client-side for flexible matching
|
||||||
|
try {
|
||||||
// Fetch all three in parallel, filter client-side for flexible matching
|
const [leadsResult, patientsResult, appointmentsResult] =
|
||||||
try {
|
await Promise.all([
|
||||||
const [leadsResult, patientsResult, appointmentsResult] = await Promise.all([
|
this.platform
|
||||||
this.platform.queryWithAuth<any>(
|
.queryWithAuth<any>(
|
||||||
`{ leads(first: 50) { edges { node {
|
`{ leads(first: 50) { edges { node {
|
||||||
id name contactName { firstName lastName }
|
id name contactName { firstName lastName }
|
||||||
contactPhone { primaryPhoneNumber }
|
contactPhone { primaryPhoneNumber }
|
||||||
source status interestedService
|
source status interestedService
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, authHeader,
|
undefined,
|
||||||
).catch(() => ({ leads: { edges: [] } })),
|
authHeader,
|
||||||
|
)
|
||||||
|
.catch(() => ({ leads: { edges: [] } })),
|
||||||
|
|
||||||
this.platform.queryWithAuth<any>(
|
this.platform
|
||||||
`{ patients(first: 50) { edges { node {
|
.queryWithAuth<any>(
|
||||||
|
`{ patients(first: 50) { edges { node {
|
||||||
id name fullName { firstName lastName }
|
id name fullName { firstName lastName }
|
||||||
phones { primaryPhoneNumber }
|
phones { primaryPhoneNumber }
|
||||||
gender dateOfBirth
|
gender dateOfBirth
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, authHeader,
|
undefined,
|
||||||
).catch(() => ({ patients: { edges: [] } })),
|
authHeader,
|
||||||
|
)
|
||||||
|
.catch(() => ({ patients: { edges: [] } })),
|
||||||
|
|
||||||
this.platform.queryWithAuth<any>(
|
this.platform
|
||||||
`{ appointments(first: 50, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
.queryWithAuth<any>(
|
||||||
|
`{ appointments(first: 50, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||||
id scheduledAt doctorName department appointmentStatus patientId
|
id scheduledAt doctorName department appointmentStatus patientId
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, authHeader,
|
undefined,
|
||||||
).catch(() => ({ appointments: { edges: [] } })),
|
authHeader,
|
||||||
]);
|
)
|
||||||
|
.catch(() => ({ appointments: { edges: [] } })),
|
||||||
|
]);
|
||||||
|
|
||||||
const q = query.toLowerCase();
|
const q = query.toLowerCase();
|
||||||
|
|
||||||
const leads = (leadsResult.leads?.edges ?? [])
|
const leads = (leadsResult.leads?.edges ?? [])
|
||||||
.map((e: any) => e.node)
|
.map((e: any) => e.node)
|
||||||
.filter((l: any) => {
|
.filter((l: any) => {
|
||||||
const name = `${l.contactName?.firstName ?? ''} ${l.contactName?.lastName ?? ''}`.toLowerCase();
|
const name =
|
||||||
const phone = l.contactPhone?.primaryPhoneNumber ?? '';
|
`${l.contactName?.firstName ?? ''} ${l.contactName?.lastName ?? ''}`.toLowerCase();
|
||||||
return name.includes(q) || phone.includes(q) || (l.name ?? '').toLowerCase().includes(q);
|
const phone = l.contactPhone?.primaryPhoneNumber ?? '';
|
||||||
})
|
return (
|
||||||
.slice(0, 5);
|
name.includes(q) ||
|
||||||
|
phone.includes(q) ||
|
||||||
|
(l.name ?? '').toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
const patients = (patientsResult.patients?.edges ?? [])
|
const patients = (patientsResult.patients?.edges ?? [])
|
||||||
.map((e: any) => e.node)
|
.map((e: any) => e.node)
|
||||||
.filter((p: any) => {
|
.filter((p: any) => {
|
||||||
const name = `${p.fullName?.firstName ?? ''} ${p.fullName?.lastName ?? ''}`.toLowerCase();
|
const name =
|
||||||
const phone = p.phones?.primaryPhoneNumber ?? '';
|
`${p.fullName?.firstName ?? ''} ${p.fullName?.lastName ?? ''}`.toLowerCase();
|
||||||
return name.includes(q) || phone.includes(q) || (p.name ?? '').toLowerCase().includes(q);
|
const phone = p.phones?.primaryPhoneNumber ?? '';
|
||||||
})
|
return (
|
||||||
.slice(0, 5);
|
name.includes(q) ||
|
||||||
|
phone.includes(q) ||
|
||||||
|
(p.name ?? '').toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
const appointments = (appointmentsResult.appointments?.edges ?? [])
|
const appointments = (appointmentsResult.appointments?.edges ?? [])
|
||||||
.map((e: any) => e.node)
|
.map((e: any) => e.node)
|
||||||
.filter((a: any) => {
|
.filter((a: any) => {
|
||||||
const doctor = (a.doctorName ?? '').toLowerCase();
|
const doctor = (a.doctorName ?? '').toLowerCase();
|
||||||
const dept = (a.department ?? '').toLowerCase();
|
const dept = (a.department ?? '').toLowerCase();
|
||||||
return doctor.includes(q) || dept.includes(q);
|
return doctor.includes(q) || dept.includes(q);
|
||||||
})
|
})
|
||||||
.slice(0, 5);
|
.slice(0, 5);
|
||||||
|
|
||||||
return { leads, patients, appointments };
|
return { leads, patients, appointments };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.error(`Search failed: ${err.message}`);
|
this.logger.error(`Search failed: ${err.message}`);
|
||||||
return { leads: [], patients: [], appointments: [] };
|
return { leads: [], patients: [], appointments: [] };
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { SearchController } from './search.controller';
|
|||||||
import { PlatformModule } from '../platform/platform.module';
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule],
|
imports: [PlatformModule],
|
||||||
controllers: [SearchController],
|
controllers: [SearchController],
|
||||||
})
|
})
|
||||||
export class SearchModule {}
|
export class SearchModule {}
|
||||||
|
|||||||
@@ -4,89 +4,111 @@ import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
|||||||
|
|
||||||
@Controller('webhooks/kookoo')
|
@Controller('webhooks/kookoo')
|
||||||
export class KookooCallbackController {
|
export class KookooCallbackController {
|
||||||
private readonly logger = new Logger(KookooCallbackController.name);
|
private readonly logger = new Logger(KookooCallbackController.name);
|
||||||
private readonly apiKey: string;
|
private readonly apiKey: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly platform: PlatformGraphqlService,
|
private readonly platform: PlatformGraphqlService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
) {
|
) {
|
||||||
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('callback')
|
||||||
|
async handleCallback(
|
||||||
|
@Body() body: Record<string, any>,
|
||||||
|
@Query() query: Record<string, any>,
|
||||||
|
) {
|
||||||
|
// Kookoo sends params as both query and body
|
||||||
|
const params = { ...query, ...body };
|
||||||
|
this.logger.log(
|
||||||
|
`Kookoo callback: sid=${params.sid} status=${params.status} phone=${params.phone_no} duration=${params.duration}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const phoneNumber = (params.phone_no ?? '').replace(/^\+?91/, '');
|
||||||
|
const status = params.status ?? 'unknown';
|
||||||
|
const duration = parseInt(params.duration ?? '0', 10);
|
||||||
|
const callerId = params.caller_id ?? '';
|
||||||
|
const startTime = params.start_time ?? null;
|
||||||
|
const endTime = params.end_time ?? null;
|
||||||
|
const sid = params.sid ?? null;
|
||||||
|
|
||||||
|
if (!phoneNumber) {
|
||||||
|
return { received: true, processed: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('callback')
|
const callStatus = status === 'answered' ? 'COMPLETED' : 'MISSED';
|
||||||
async handleCallback(@Body() body: Record<string, any>, @Query() query: Record<string, any>) {
|
const authHeader = this.apiKey ? `Bearer ${this.apiKey}` : '';
|
||||||
// Kookoo sends params as both query and body
|
|
||||||
const params = { ...query, ...body };
|
|
||||||
this.logger.log(`Kookoo callback: sid=${params.sid} status=${params.status} phone=${params.phone_no} duration=${params.duration}`);
|
|
||||||
|
|
||||||
const phoneNumber = (params.phone_no ?? '').replace(/^\+?91/, '');
|
if (!authHeader) {
|
||||||
const status = params.status ?? 'unknown';
|
this.logger.warn('No PLATFORM_API_KEY — cannot write call records');
|
||||||
const duration = parseInt(params.duration ?? '0', 10);
|
return { received: true, processed: false };
|
||||||
const callerId = params.caller_id ?? '';
|
|
||||||
const startTime = params.start_time ?? null;
|
|
||||||
const endTime = params.end_time ?? null;
|
|
||||||
const sid = params.sid ?? null;
|
|
||||||
|
|
||||||
if (!phoneNumber) {
|
|
||||||
return { received: true, processed: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const callStatus = status === 'answered' ? 'COMPLETED' : 'MISSED';
|
|
||||||
const authHeader = this.apiKey ? `Bearer ${this.apiKey}` : '';
|
|
||||||
|
|
||||||
if (!authHeader) {
|
|
||||||
this.logger.warn('No PLATFORM_API_KEY — cannot write call records');
|
|
||||||
return { received: true, processed: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Create call record
|
|
||||||
const callResult = await this.platform.queryWithAuth<any>(
|
|
||||||
`mutation($data: CallCreateInput!) { createCall(data: $data) { id } }`,
|
|
||||||
{
|
|
||||||
data: {
|
|
||||||
name: `Outbound — ${phoneNumber}`,
|
|
||||||
direction: 'OUTBOUND',
|
|
||||||
callStatus,
|
|
||||||
callerNumber: { primaryPhoneNumber: `+91${phoneNumber}` },
|
|
||||||
startedAt: startTime ? new Date(startTime).toISOString() : new Date().toISOString(),
|
|
||||||
endedAt: endTime ? new Date(endTime).toISOString() : null,
|
|
||||||
durationSec: duration,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
authHeader,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(`Created outbound call record: ${callResult.createCall.id} (${callStatus}, ${duration}s)`);
|
|
||||||
|
|
||||||
// Try to match to a lead
|
|
||||||
const leadResult = await this.platform.queryWithAuth<any>(
|
|
||||||
`{ leads(first: 50) { edges { node { id name contactPhone { primaryPhoneNumber } } } } }`,
|
|
||||||
undefined,
|
|
||||||
authHeader,
|
|
||||||
);
|
|
||||||
const leads = leadResult.leads.edges.map((e: any) => e.node);
|
|
||||||
const cleanPhone = phoneNumber.replace(/\D/g, '');
|
|
||||||
const matchedLead = leads.find((l: any) => {
|
|
||||||
const lp = (l.contactPhone?.primaryPhoneNumber ?? '').replace(/\D/g, '');
|
|
||||||
return lp.endsWith(cleanPhone) || cleanPhone.endsWith(lp);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (matchedLead) {
|
|
||||||
await this.platform.queryWithAuth<any>(
|
|
||||||
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
|
||||||
{ id: callResult.createCall.id, data: { leadId: matchedLead.id } },
|
|
||||||
authHeader,
|
|
||||||
);
|
|
||||||
this.logger.log(`Linked call to lead ${matchedLead.id} (${matchedLead.name})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { received: true, processed: true, callId: callResult.createCall.id };
|
|
||||||
} catch (err: any) {
|
|
||||||
const responseData = err?.response?.data ? JSON.stringify(err.response.data) : '';
|
|
||||||
this.logger.error(`Kookoo callback processing failed: ${err.message} ${responseData}`);
|
|
||||||
return { received: true, processed: false };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create call record
|
||||||
|
const callResult = await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($data: CallCreateInput!) { createCall(data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
name: `Outbound — ${phoneNumber}`,
|
||||||
|
direction: 'OUTBOUND',
|
||||||
|
callStatus,
|
||||||
|
callerNumber: { primaryPhoneNumber: `+91${phoneNumber}` },
|
||||||
|
startedAt: startTime
|
||||||
|
? new Date(startTime).toISOString()
|
||||||
|
: new Date().toISOString(),
|
||||||
|
endedAt: endTime ? new Date(endTime).toISOString() : null,
|
||||||
|
durationSec: duration,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
authHeader,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Created outbound call record: ${callResult.createCall.id} (${callStatus}, ${duration}s)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try to match to a lead
|
||||||
|
const leadResult = await this.platform.queryWithAuth<any>(
|
||||||
|
`{ leads(first: 50) { edges { node { id name contactPhone { primaryPhoneNumber } } } } }`,
|
||||||
|
undefined,
|
||||||
|
authHeader,
|
||||||
|
);
|
||||||
|
const leads = leadResult.leads.edges.map((e: any) => e.node);
|
||||||
|
const cleanPhone = phoneNumber.replace(/\D/g, '');
|
||||||
|
const matchedLead = leads.find((l: any) => {
|
||||||
|
const lp = (l.contactPhone?.primaryPhoneNumber ?? '').replace(
|
||||||
|
/\D/g,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
return lp.endsWith(cleanPhone) || cleanPhone.endsWith(lp);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matchedLead) {
|
||||||
|
await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: callResult.createCall.id, data: { leadId: matchedLead.id } },
|
||||||
|
authHeader,
|
||||||
|
);
|
||||||
|
this.logger.log(
|
||||||
|
`Linked call to lead ${matchedLead.id} (${matchedLead.name})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
received: true,
|
||||||
|
processed: true,
|
||||||
|
callId: callResult.createCall.id,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
const responseData = err?.response?.data
|
||||||
|
? JSON.stringify(err.response.data)
|
||||||
|
: '';
|
||||||
|
this.logger.error(
|
||||||
|
`Kookoo callback processing failed: ${err.message} ${responseData}`,
|
||||||
|
);
|
||||||
|
return { received: true, processed: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,224 +4,280 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
|
|
||||||
@Controller('webhooks/ozonetel')
|
@Controller('webhooks/ozonetel')
|
||||||
export class MissedCallWebhookController {
|
export class MissedCallWebhookController {
|
||||||
private readonly logger = new Logger(MissedCallWebhookController.name);
|
private readonly logger = new Logger(MissedCallWebhookController.name);
|
||||||
private readonly apiKey: string;
|
private readonly apiKey: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly platform: PlatformGraphqlService,
|
private readonly platform: PlatformGraphqlService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
) {
|
) {
|
||||||
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('missed-call')
|
||||||
|
async handleCallWebhook(@Body() body: Record<string, any>) {
|
||||||
|
// Ozonetel sends the payload as a JSON string inside a "data" field
|
||||||
|
let payload: Record<string, any>;
|
||||||
|
try {
|
||||||
|
payload = typeof body.data === 'string' ? JSON.parse(body.data) : body;
|
||||||
|
} catch {
|
||||||
|
payload = body;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('missed-call')
|
this.logger.log(
|
||||||
async handleCallWebhook(@Body() body: Record<string, any>) {
|
`Call webhook: ${payload.CallerID} | ${payload.Status} | ${payload.Type}`,
|
||||||
// Ozonetel sends the payload as a JSON string inside a "data" field
|
);
|
||||||
let payload: Record<string, any>;
|
|
||||||
try {
|
|
||||||
payload = typeof body.data === 'string' ? JSON.parse(body.data) : body;
|
|
||||||
} catch {
|
|
||||||
payload = body;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Call webhook: ${payload.CallerID} | ${payload.Status} | ${payload.Type}`);
|
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;
|
||||||
|
|
||||||
const callerPhone = (payload.CallerID ?? '').replace(/^\+?91/, '');
|
if (!callerPhone) {
|
||||||
const status = payload.Status; // NotAnswered, Answered, Abandoned
|
this.logger.warn('No caller phone in webhook — skipping');
|
||||||
const type = payload.Type; // InBound, OutBound
|
return { received: true, processed: false };
|
||||||
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) };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createCall(data: {
|
// Determine call status for our platform
|
||||||
callerPhone: string;
|
const callStatus = status === 'Answered' ? 'COMPLETED' : 'MISSED';
|
||||||
direction: string;
|
const direction = type === 'InBound' ? 'INBOUND' : 'OUTBOUND';
|
||||||
callStatus: string;
|
|
||||||
agentName: string | null;
|
|
||||||
startTime: string | null;
|
|
||||||
endTime: string | null;
|
|
||||||
duration: number;
|
|
||||||
recordingUrl: string | null;
|
|
||||||
disposition: string | null;
|
|
||||||
ucid: string | null;
|
|
||||||
}, authHeader: string): Promise<string> {
|
|
||||||
const callData: Record<string, any> = {
|
|
||||||
name: `${data.direction === 'INBOUND' ? 'Inbound' : 'Outbound'} — ${data.callerPhone}`,
|
|
||||||
direction: data.direction,
|
|
||||||
callStatus: data.callStatus,
|
|
||||||
callerNumber: { primaryPhoneNumber: `+91${data.callerPhone}` },
|
|
||||||
agentName: data.agentName,
|
|
||||||
startedAt: data.startTime ? new Date(data.startTime).toISOString() : null,
|
|
||||||
endedAt: data.endTime ? new Date(data.endTime).toISOString() : null,
|
|
||||||
durationSec: data.duration,
|
|
||||||
disposition: this.mapDisposition(data.disposition),
|
|
||||||
};
|
|
||||||
if (data.recordingUrl) {
|
|
||||||
callData.recording = { primaryLinkUrl: data.recordingUrl, primaryLinkLabel: 'Recording' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.platform.queryWithAuth<any>(
|
// Use API key auth for server-to-server writes
|
||||||
`mutation($data: CallCreateInput!) { createCall(data: $data) { id } }`,
|
const authHeader = this.apiKey ? `Bearer ${this.apiKey}` : '';
|
||||||
{ data: callData },
|
if (!authHeader) {
|
||||||
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,
|
||||||
);
|
);
|
||||||
return result.createCall.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async findLeadByPhone(phone: string, authHeader: string): Promise<{ id: string; name: string; contactAttempts: number } | null> {
|
// Step 5: Update lead contact timestamps
|
||||||
const result = await this.platform.queryWithAuth<any>(
|
await this.updateLead(
|
||||||
`{ leads(first: 50) { edges { node { id name contactPhone { primaryPhoneNumber } contactAttempts lastContacted } } } }`,
|
lead.id,
|
||||||
undefined,
|
{
|
||||||
authHeader,
|
lastContacted: startTime
|
||||||
|
? new Date(startTime).toISOString()
|
||||||
|
: new Date().toISOString(),
|
||||||
|
contactAttempts: (lead.contactAttempts ?? 0) + 1,
|
||||||
|
},
|
||||||
|
authHeader,
|
||||||
);
|
);
|
||||||
const leads = result.leads.edges.map((e: any) => e.node);
|
|
||||||
const cleanPhone = phone.replace(/\D/g, '');
|
|
||||||
|
|
||||||
return leads.find((l: any) => {
|
this.logger.log(
|
||||||
const lp = (l.contactPhone?.primaryPhoneNumber ?? '').replace(/\D/g, '');
|
`Linked call to lead ${lead.id} (${lead.name}), activity created`,
|
||||||
return lp.endsWith(cleanPhone) || cleanPhone.endsWith(lp);
|
|
||||||
}) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async updateCall(callId: string, data: Record<string, any>, authHeader: string): Promise<void> {
|
|
||||||
await this.platform.queryWithAuth<any>(
|
|
||||||
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
|
||||||
{ id: callId, data },
|
|
||||||
authHeader,
|
|
||||||
);
|
);
|
||||||
}
|
} else {
|
||||||
|
this.logger.log(
|
||||||
private async createLeadActivity(data: {
|
`No matching lead for ${callerPhone} — call record created without lead link`,
|
||||||
leadId: string;
|
|
||||||
activityType: string;
|
|
||||||
summary: string;
|
|
||||||
channel: string;
|
|
||||||
performedBy: string;
|
|
||||||
durationSeconds: number;
|
|
||||||
outcome: string;
|
|
||||||
}, authHeader: string): Promise<void> {
|
|
||||||
await this.platform.queryWithAuth<any>(
|
|
||||||
`mutation($data: LeadActivityCreateInput!) { createLeadActivity(data: $data) { id } }`,
|
|
||||||
{
|
|
||||||
data: {
|
|
||||||
name: data.summary.substring(0, 80),
|
|
||||||
activityType: data.activityType,
|
|
||||||
summary: data.summary,
|
|
||||||
occurredAt: new Date().toISOString(),
|
|
||||||
performedBy: data.performedBy,
|
|
||||||
channel: data.channel,
|
|
||||||
durationSec: data.durationSeconds,
|
|
||||||
outcome: data.outcome,
|
|
||||||
leadId: data.leadId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
authHeader,
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
received: true,
|
||||||
|
processed: true,
|
||||||
|
callId,
|
||||||
|
leadId: lead?.id ?? null,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
const responseData = err?.response?.data
|
||||||
|
? JSON.stringify(err.response.data)
|
||||||
|
: '';
|
||||||
|
this.logger.error(
|
||||||
|
`Webhook processing failed: ${err.message} ${responseData}`,
|
||||||
|
);
|
||||||
|
return { received: true, processed: false, error: String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createCall(
|
||||||
|
data: {
|
||||||
|
callerPhone: string;
|
||||||
|
direction: string;
|
||||||
|
callStatus: string;
|
||||||
|
agentName: string | null;
|
||||||
|
startTime: string | null;
|
||||||
|
endTime: string | null;
|
||||||
|
duration: number;
|
||||||
|
recordingUrl: string | null;
|
||||||
|
disposition: string | null;
|
||||||
|
ucid: string | null;
|
||||||
|
},
|
||||||
|
authHeader: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const callData: Record<string, any> = {
|
||||||
|
name: `${data.direction === 'INBOUND' ? 'Inbound' : 'Outbound'} — ${data.callerPhone}`,
|
||||||
|
direction: data.direction,
|
||||||
|
callStatus: data.callStatus,
|
||||||
|
callerNumber: { primaryPhoneNumber: `+91${data.callerPhone}` },
|
||||||
|
agentName: data.agentName,
|
||||||
|
startedAt: data.startTime ? new Date(data.startTime).toISOString() : null,
|
||||||
|
endedAt: data.endTime ? new Date(data.endTime).toISOString() : null,
|
||||||
|
durationSec: data.duration,
|
||||||
|
disposition: this.mapDisposition(data.disposition),
|
||||||
|
};
|
||||||
|
if (data.recordingUrl) {
|
||||||
|
callData.recording = {
|
||||||
|
primaryLinkUrl: data.recordingUrl,
|
||||||
|
primaryLinkLabel: 'Recording',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateLead(leadId: string, data: Record<string, any>, authHeader: string): Promise<void> {
|
const result = await this.platform.queryWithAuth<any>(
|
||||||
await this.platform.queryWithAuth<any>(
|
`mutation($data: CallCreateInput!) { createCall(data: $data) { id } }`,
|
||||||
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
{ data: callData },
|
||||||
{ id: leadId, data },
|
authHeader,
|
||||||
authHeader,
|
);
|
||||||
|
return result.createCall.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findLeadByPhone(
|
||||||
|
phone: string,
|
||||||
|
authHeader: string,
|
||||||
|
): Promise<{ id: string; name: string; contactAttempts: number } | null> {
|
||||||
|
const result = await this.platform.queryWithAuth<any>(
|
||||||
|
`{ leads(first: 50) { edges { node { id name contactPhone { primaryPhoneNumber } contactAttempts lastContacted } } } }`,
|
||||||
|
undefined,
|
||||||
|
authHeader,
|
||||||
|
);
|
||||||
|
const leads = result.leads.edges.map((e: any) => e.node);
|
||||||
|
const cleanPhone = phone.replace(/\D/g, '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
leads.find((l: any) => {
|
||||||
|
const lp = (l.contactPhone?.primaryPhoneNumber ?? '').replace(
|
||||||
|
/\D/g,
|
||||||
|
'',
|
||||||
);
|
);
|
||||||
}
|
return lp.endsWith(cleanPhone) || cleanPhone.endsWith(lp);
|
||||||
|
}) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private parseDuration(timeStr: string): number {
|
private async updateCall(
|
||||||
const parts = timeStr.split(':').map(Number);
|
callId: string,
|
||||||
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
data: Record<string, any>,
|
||||||
if (parts.length === 2) return parts[0] * 60 + parts[1];
|
authHeader: string,
|
||||||
return parseInt(timeStr) || 0;
|
): Promise<void> {
|
||||||
}
|
await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: callId, data },
|
||||||
|
authHeader,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private mapDisposition(disposition: string | null): string | null {
|
private async createLeadActivity(
|
||||||
if (!disposition) return null;
|
data: {
|
||||||
const map: Record<string, string> = {
|
leadId: string;
|
||||||
'General Enquiry': 'INFO_PROVIDED',
|
activityType: string;
|
||||||
'Appointment Booked': 'APPOINTMENT_BOOKED',
|
summary: string;
|
||||||
'Follow Up': 'FOLLOW_UP_SCHEDULED',
|
channel: string;
|
||||||
'Not Interested': 'CALLBACK_REQUESTED',
|
performedBy: string;
|
||||||
'Wrong Number': 'WRONG_NUMBER',
|
durationSeconds: number;
|
||||||
};
|
outcome: string;
|
||||||
return map[disposition] ?? null;
|
},
|
||||||
}
|
authHeader: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($data: LeadActivityCreateInput!) { createLeadActivity(data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
name: data.summary.substring(0, 80),
|
||||||
|
activityType: data.activityType,
|
||||||
|
summary: data.summary,
|
||||||
|
occurredAt: new Date().toISOString(),
|
||||||
|
performedBy: data.performedBy,
|
||||||
|
channel: data.channel,
|
||||||
|
durationSec: data.durationSeconds,
|
||||||
|
outcome: data.outcome,
|
||||||
|
leadId: data.leadId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
authHeader,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateLead(
|
||||||
|
leadId: string,
|
||||||
|
data: Record<string, any>,
|
||||||
|
authHeader: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: leadId, data },
|
||||||
|
authHeader,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseDuration(timeStr: string): number {
|
||||||
|
const parts = timeStr.split(':').map(Number);
|
||||||
|
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||||
|
if (parts.length === 2) return parts[0] * 60 + parts[1];
|
||||||
|
return parseInt(timeStr) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapDisposition(disposition: string | null): string | null {
|
||||||
|
if (!disposition) return null;
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'General Enquiry': 'INFO_PROVIDED',
|
||||||
|
'Appointment Booked': 'APPOINTMENT_BOOKED',
|
||||||
|
'Follow Up': 'FOLLOW_UP_SCHEDULED',
|
||||||
|
'Not Interested': 'CALLBACK_REQUESTED',
|
||||||
|
'Wrong Number': 'WRONG_NUMBER',
|
||||||
|
};
|
||||||
|
return map[disposition] ?? null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,87 +5,103 @@ import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
|||||||
|
|
||||||
// Normalize phone to +91XXXXXXXXXX format
|
// Normalize phone to +91XXXXXXXXXX format
|
||||||
export function normalizePhone(raw: string): string {
|
export function normalizePhone(raw: string): string {
|
||||||
let digits = raw.replace(/[^0-9]/g, '');
|
let digits = raw.replace(/[^0-9]/g, '');
|
||||||
// Strip leading country code variations: 0091, 91, 0
|
// Strip leading country code variations: 0091, 91, 0
|
||||||
if (digits.startsWith('0091')) digits = digits.slice(4);
|
if (digits.startsWith('0091')) digits = digits.slice(4);
|
||||||
else if (digits.startsWith('91') && digits.length > 10) digits = digits.slice(2);
|
else if (digits.startsWith('91') && digits.length > 10)
|
||||||
else if (digits.startsWith('0') && digits.length > 10) digits = digits.slice(1);
|
digits = digits.slice(2);
|
||||||
return `+91${digits.slice(-10)}`;
|
else if (digits.startsWith('0') && digits.length > 10)
|
||||||
|
digits = digits.slice(1);
|
||||||
|
return `+91${digits.slice(-10)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MissedQueueService implements OnModuleInit {
|
export class MissedQueueService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(MissedQueueService.name);
|
private readonly logger = new Logger(MissedQueueService.name);
|
||||||
private readonly pollIntervalMs: number;
|
private readonly pollIntervalMs: number;
|
||||||
private readonly processedUcids = new Set<string>();
|
private readonly processedUcids = new Set<string>();
|
||||||
private assignmentMutex = false;
|
private assignmentMutex = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly platform: PlatformGraphqlService,
|
private readonly platform: PlatformGraphqlService,
|
||||||
private readonly ozonetel: OzonetelAgentService,
|
private readonly ozonetel: OzonetelAgentService,
|
||||||
) {
|
) {
|
||||||
this.pollIntervalMs = this.config.get<number>('missedQueue.pollIntervalMs', 30000);
|
this.pollIntervalMs = this.config.get<number>(
|
||||||
|
'missedQueue.pollIntervalMs',
|
||||||
|
30000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
this.logger.log(
|
||||||
|
`Starting missed call ingestion polling every ${this.pollIntervalMs}ms`,
|
||||||
|
);
|
||||||
|
setInterval(
|
||||||
|
() =>
|
||||||
|
this.ingest().catch((err) =>
|
||||||
|
this.logger.error('Ingestion failed', err),
|
||||||
|
),
|
||||||
|
this.pollIntervalMs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async ingest(): Promise<{ created: number; updated: number }> {
|
||||||
|
let created = 0;
|
||||||
|
let updated = 0;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const fiveMinAgo = new Date(now.getTime() - 5 * 60 * 1000);
|
||||||
|
const format = (d: Date) => d.toISOString().replace('T', ' ').slice(0, 19);
|
||||||
|
|
||||||
|
let abandonCalls: any[];
|
||||||
|
try {
|
||||||
|
abandonCalls = await this.ozonetel.getAbandonCalls({
|
||||||
|
fromTime: format(fiveMinAgo),
|
||||||
|
toTime: format(now),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to fetch abandon calls: ${err}`);
|
||||||
|
return { created: 0, updated: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
onModuleInit() {
|
if (!abandonCalls?.length) return { created: 0, updated: 0 };
|
||||||
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 }> {
|
for (const call of abandonCalls) {
|
||||||
let created = 0;
|
const ucid = call.monitorUCID;
|
||||||
let updated = 0;
|
if (!ucid || this.processedUcids.has(ucid)) continue;
|
||||||
|
this.processedUcids.add(ucid);
|
||||||
|
|
||||||
const now = new Date();
|
const phone = normalizePhone(call.callerID || '');
|
||||||
const fiveMinAgo = new Date(now.getTime() - 5 * 60 * 1000);
|
if (!phone || phone.length < 13) continue;
|
||||||
const format = (d: Date) => d.toISOString().replace('T', ' ').slice(0, 19);
|
|
||||||
|
|
||||||
let abandonCalls: any[];
|
const did = call.did || '';
|
||||||
try {
|
const callTime = call.callTime || new Date().toISOString();
|
||||||
abandonCalls = await this.ozonetel.getAbandonCalls({ fromTime: format(fiveMinAgo), toTime: format(now) });
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(`Failed to fetch abandon calls: ${err}`);
|
|
||||||
return { created: 0, updated: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!abandonCalls?.length) return { created: 0, updated: 0 };
|
try {
|
||||||
|
const existing = await this.platform.query<any>(
|
||||||
for (const call of abandonCalls) {
|
`{ calls(first: 1, filter: {
|
||||||
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 = call.callTime || new Date().toISOString();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const existing = await this.platform.query<any>(
|
|
||||||
`{ calls(first: 1, filter: {
|
|
||||||
callbackstatus: { eq: PENDING_CALLBACK },
|
callbackstatus: { eq: PENDING_CALLBACK },
|
||||||
callerNumber: { primaryPhoneNumber: { eq: "${phone}" } }
|
callerNumber: { primaryPhoneNumber: { eq: "${phone}" } }
|
||||||
}) { edges { node { id missedcallcount } } } }`,
|
}) { edges { node { id missedcallcount } } } }`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const existingNode = existing?.calls?.edges?.[0]?.node;
|
const existingNode = existing?.calls?.edges?.[0]?.node;
|
||||||
|
|
||||||
if (existingNode) {
|
if (existingNode) {
|
||||||
const newCount = (existingNode.missedcallcount || 1) + 1;
|
const newCount = (existingNode.missedcallcount || 1) + 1;
|
||||||
await this.platform.query<any>(
|
await this.platform.query<any>(
|
||||||
`mutation { updateCall(id: "${existingNode.id}", data: {
|
`mutation { updateCall(id: "${existingNode.id}", data: {
|
||||||
missedcallcount: ${newCount},
|
missedcallcount: ${newCount},
|
||||||
startedAt: "${callTime}",
|
startedAt: "${callTime}",
|
||||||
callsourcenumber: "${did}"
|
callsourcenumber: "${did}"
|
||||||
}) { id } }`,
|
}) { id } }`,
|
||||||
);
|
);
|
||||||
updated++;
|
updated++;
|
||||||
this.logger.log(`Dedup missed call ${phone}: count now ${newCount}`);
|
this.logger.log(`Dedup missed call ${phone}: count now ${newCount}`);
|
||||||
} else {
|
} else {
|
||||||
await this.platform.query<any>(
|
await this.platform.query<any>(
|
||||||
`mutation { createCall(data: {
|
`mutation { createCall(data: {
|
||||||
callStatus: MISSED,
|
callStatus: MISSED,
|
||||||
direction: INBOUND,
|
direction: INBOUND,
|
||||||
callerNumber: { primaryPhoneNumber: "${phone}", primaryPhoneCallingCode: "+91" },
|
callerNumber: { primaryPhoneNumber: "${phone}", primaryPhoneCallingCode: "+91" },
|
||||||
@@ -94,34 +110,35 @@ export class MissedQueueService implements OnModuleInit {
|
|||||||
missedcallcount: 1,
|
missedcallcount: 1,
|
||||||
startedAt: "${callTime}"
|
startedAt: "${callTime}"
|
||||||
}) { id } }`,
|
}) { id } }`,
|
||||||
);
|
);
|
||||||
created++;
|
created++;
|
||||||
this.logger.log(`Created missed call record for ${phone}`);
|
this.logger.log(`Created missed call record for ${phone}`);
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(`Failed to process abandon call ${ucid}: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
// Trim processedUcids to prevent unbounded growth
|
this.logger.warn(`Failed to process abandon call ${ucid}: ${err}`);
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async assignNext(agentName: string): Promise<any | null> {
|
// Trim processedUcids to prevent unbounded growth
|
||||||
if (this.assignmentMutex) return null;
|
if (this.processedUcids.size > 500) {
|
||||||
this.assignmentMutex = true;
|
const arr = Array.from(this.processedUcids);
|
||||||
|
this.processedUcids.clear();
|
||||||
|
arr.slice(-200).forEach((u) => this.processedUcids.add(u));
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
if (created || updated)
|
||||||
// Find oldest unassigned PENDING_CALLBACK call (empty agentName)
|
this.logger.log(`Ingestion: ${created} created, ${updated} updated`);
|
||||||
let result = await this.platform.query<any>(
|
return { created, updated };
|
||||||
`{ calls(first: 1, filter: {
|
}
|
||||||
|
|
||||||
|
async assignNext(agentName: string): Promise<any | null> {
|
||||||
|
if (this.assignmentMutex) return null;
|
||||||
|
this.assignmentMutex = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find oldest unassigned PENDING_CALLBACK call (empty agentName)
|
||||||
|
let result = await this.platform.query<any>(
|
||||||
|
`{ calls(first: 1, filter: {
|
||||||
callbackstatus: { eq: PENDING_CALLBACK },
|
callbackstatus: { eq: PENDING_CALLBACK },
|
||||||
agentName: { eq: "" }
|
agentName: { eq: "" }
|
||||||
}, orderBy: [{ startedAt: AscNullsLast }]) {
|
}, orderBy: [{ startedAt: AscNullsLast }]) {
|
||||||
@@ -130,14 +147,14 @@ export class MissedQueueService implements OnModuleInit {
|
|||||||
startedAt callsourcenumber missedcallcount
|
startedAt callsourcenumber missedcallcount
|
||||||
} }
|
} }
|
||||||
} }`,
|
} }`,
|
||||||
);
|
);
|
||||||
|
|
||||||
let call = result?.calls?.edges?.[0]?.node;
|
let call = result?.calls?.edges?.[0]?.node;
|
||||||
|
|
||||||
// Also check for null agentName
|
// Also check for null agentName
|
||||||
if (!call) {
|
if (!call) {
|
||||||
result = await this.platform.query<any>(
|
result = await this.platform.query<any>(
|
||||||
`{ calls(first: 1, filter: {
|
`{ calls(first: 1, filter: {
|
||||||
callbackstatus: { eq: PENDING_CALLBACK },
|
callbackstatus: { eq: PENDING_CALLBACK },
|
||||||
agentName: { is: NULL }
|
agentName: { is: NULL }
|
||||||
}, orderBy: [{ startedAt: AscNullsLast }]) {
|
}, orderBy: [{ startedAt: AscNullsLast }]) {
|
||||||
@@ -146,80 +163,117 @@ export class MissedQueueService implements OnModuleInit {
|
|||||||
startedAt callsourcenumber missedcallcount
|
startedAt callsourcenumber missedcallcount
|
||||||
} }
|
} }
|
||||||
} }`,
|
} }`,
|
||||||
);
|
|
||||||
call = result?.calls?.edges?.[0]?.node;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!call) return null;
|
|
||||||
|
|
||||||
await this.platform.query<any>(
|
|
||||||
`mutation { updateCall(id: "${call.id}", data: { agentName: "${agentName}" }) { id } }`,
|
|
||||||
);
|
|
||||||
this.logger.log(`Assigned missed call ${call.id} to ${agentName}`);
|
|
||||||
return call;
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(`Assignment failed: ${err}`);
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
this.assignmentMutex = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateStatus(callId: string, status: string, authHeader: string): Promise<any> {
|
|
||||||
const validStatuses = ['PENDING_CALLBACK', 'CALLBACK_ATTEMPTED', 'CALLBACK_COMPLETED', 'INVALID', 'WRONG_NUMBER'];
|
|
||||||
if (!validStatuses.includes(status)) {
|
|
||||||
throw new Error(`Invalid status: ${status}. Must be one of: ${validStatuses.join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataParts: string[] = [`callbackstatus: ${status}`];
|
|
||||||
if (status === 'CALLBACK_ATTEMPTED') {
|
|
||||||
dataParts.push(`callbackattemptedat: "${new Date().toISOString()}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.platform.queryWithAuth<any>(
|
|
||||||
`mutation { updateCall(id: "${callId}", data: { ${dataParts.join(', ')} }) { id callbackstatus callbackattemptedat } }`,
|
|
||||||
undefined,
|
|
||||||
authHeader,
|
|
||||||
);
|
);
|
||||||
|
call = result?.calls?.edges?.[0]?.node;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!call) return null;
|
||||||
|
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation { updateCall(id: "${call.id}", data: { agentName: "${agentName}" }) { id } }`,
|
||||||
|
);
|
||||||
|
this.logger.log(`Assigned missed call ${call.id} to ${agentName}`);
|
||||||
|
return call;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Assignment failed: ${err}`);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
this.assignmentMutex = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStatus(
|
||||||
|
callId: string,
|
||||||
|
status: string,
|
||||||
|
authHeader: string,
|
||||||
|
): Promise<any> {
|
||||||
|
const validStatuses = [
|
||||||
|
'PENDING_CALLBACK',
|
||||||
|
'CALLBACK_ATTEMPTED',
|
||||||
|
'CALLBACK_COMPLETED',
|
||||||
|
'INVALID',
|
||||||
|
'WRONG_NUMBER',
|
||||||
|
];
|
||||||
|
if (!validStatuses.includes(status)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid status: ${status}. Must be one of: ${validStatuses.join(', ')}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMissedQueue(agentName: string, authHeader: string): Promise<{
|
const dataParts: string[] = [`callbackstatus: ${status}`];
|
||||||
pending: any[];
|
if (status === 'CALLBACK_ATTEMPTED') {
|
||||||
attempted: any[];
|
dataParts.push(`callbackattemptedat: "${new Date().toISOString()}"`);
|
||||||
completed: any[];
|
}
|
||||||
invalid: any[];
|
|
||||||
}> {
|
return this.platform.queryWithAuth<any>(
|
||||||
const fields = `id name createdAt direction callStatus agentName
|
`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
|
||||||
callerNumber { primaryPhoneNumber }
|
callerNumber { primaryPhoneNumber }
|
||||||
startedAt endedAt durationSec disposition leadId
|
startedAt endedAt durationSec disposition leadId
|
||||||
callbackstatus callsourcenumber missedcallcount callbackattemptedat`;
|
callbackstatus callsourcenumber missedcallcount callbackattemptedat`;
|
||||||
|
|
||||||
const buildQuery = (status: string) => `{ calls(first: 50, filter: {
|
const buildQuery = (status: string) => `{ calls(first: 50, filter: {
|
||||||
agentName: { eq: "${agentName}" },
|
agentName: { eq: "${agentName}" },
|
||||||
callStatus: { eq: MISSED },
|
callStatus: { eq: MISSED },
|
||||||
callbackstatus: { eq: ${status} }
|
callbackstatus: { eq: ${status} }
|
||||||
}, orderBy: [{ startedAt: AscNullsLast }]) { edges { node { ${fields} } } } }`;
|
}, orderBy: [{ startedAt: AscNullsLast }]) { edges { node { ${fields} } } } }`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [pending, attempted, completed, invalid, wrongNumber] = await Promise.all([
|
const [pending, attempted, completed, invalid, wrongNumber] =
|
||||||
this.platform.queryWithAuth<any>(buildQuery('PENDING_CALLBACK'), undefined, authHeader),
|
await Promise.all([
|
||||||
this.platform.queryWithAuth<any>(buildQuery('CALLBACK_ATTEMPTED'), undefined, authHeader),
|
this.platform.queryWithAuth<any>(
|
||||||
this.platform.queryWithAuth<any>(buildQuery('CALLBACK_COMPLETED'), undefined, authHeader),
|
buildQuery('PENDING_CALLBACK'),
|
||||||
this.platform.queryWithAuth<any>(buildQuery('INVALID'), undefined, authHeader),
|
undefined,
|
||||||
this.platform.queryWithAuth<any>(buildQuery('WRONG_NUMBER'), undefined, authHeader),
|
authHeader,
|
||||||
]);
|
),
|
||||||
|
this.platform.queryWithAuth<any>(
|
||||||
|
buildQuery('CALLBACK_ATTEMPTED'),
|
||||||
|
undefined,
|
||||||
|
authHeader,
|
||||||
|
),
|
||||||
|
this.platform.queryWithAuth<any>(
|
||||||
|
buildQuery('CALLBACK_COMPLETED'),
|
||||||
|
undefined,
|
||||||
|
authHeader,
|
||||||
|
),
|
||||||
|
this.platform.queryWithAuth<any>(
|
||||||
|
buildQuery('INVALID'),
|
||||||
|
undefined,
|
||||||
|
authHeader,
|
||||||
|
),
|
||||||
|
this.platform.queryWithAuth<any>(
|
||||||
|
buildQuery('WRONG_NUMBER'),
|
||||||
|
undefined,
|
||||||
|
authHeader,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
const extract = (r: any) => r?.calls?.edges?.map((e: any) => e.node) ?? [];
|
const extract = (r: any) =>
|
||||||
|
r?.calls?.edges?.map((e: any) => e.node) ?? [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pending: extract(pending),
|
pending: extract(pending),
|
||||||
attempted: extract(attempted),
|
attempted: extract(attempted),
|
||||||
completed: [...extract(completed), ...extract(wrongNumber)],
|
completed: [...extract(completed), ...extract(wrongNumber)],
|
||||||
invalid: extract(invalid),
|
invalid: extract(invalid),
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Failed to fetch missed queue: ${err}`);
|
this.logger.warn(`Failed to fetch missed queue: ${err}`);
|
||||||
return { pending: [], attempted: [], completed: [], invalid: [] };
|
return { pending: [], attempted: [], completed: [], invalid: [] };
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +1,72 @@
|
|||||||
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 { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
import { WorklistService } from './worklist.service';
|
import { WorklistService } from './worklist.service';
|
||||||
import { MissedQueueService } from './missed-queue.service';
|
import { MissedQueueService } from './missed-queue.service';
|
||||||
|
|
||||||
@Controller('api/worklist')
|
@Controller('api/worklist')
|
||||||
export class WorklistController {
|
export class WorklistController {
|
||||||
private readonly logger = new Logger(WorklistController.name);
|
private readonly logger = new Logger(WorklistController.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly worklist: WorklistService,
|
private readonly worklist: WorklistService,
|
||||||
private readonly missedQueue: MissedQueueService,
|
private readonly missedQueue: MissedQueueService,
|
||||||
private readonly platform: PlatformGraphqlService,
|
private readonly platform: PlatformGraphqlService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async getWorklist(@Headers('authorization') authHeader: string) {
|
async getWorklist(@Headers('authorization') authHeader: string) {
|
||||||
if (!authHeader) {
|
if (!authHeader) {
|
||||||
throw new HttpException('Authorization required', 401);
|
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('missed-queue')
|
const agentName = await this.resolveAgentName(authHeader);
|
||||||
async getMissedQueue(@Headers('authorization') authHeader: string) {
|
this.logger.log(`Fetching worklist for agent: ${agentName}`);
|
||||||
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')
|
return this.worklist.getWorklist(agentName, authHeader);
|
||||||
async updateMissedCallStatus(
|
}
|
||||||
@Param('id') id: string,
|
|
||||||
@Headers('authorization') authHeader: string,
|
|
||||||
@Body() body: { status: string },
|
|
||||||
) {
|
|
||||||
if (!authHeader) throw new HttpException('Authorization header required', 401);
|
|
||||||
if (!body.status) throw new HttpException('status is required', 400);
|
|
||||||
return this.missedQueue.updateStatus(id, body.status, authHeader);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async resolveAgentName(authHeader: string): Promise<string> {
|
@Get('missed-queue')
|
||||||
try {
|
async getMissedQueue(@Headers('authorization') authHeader: string) {
|
||||||
const data = await this.platform.queryWithAuth<any>(
|
if (!authHeader)
|
||||||
`{ currentUser { workspaceMember { name { firstName lastName } } } }`,
|
throw new HttpException('Authorization header required', 401);
|
||||||
undefined,
|
const agentName = await this.resolveAgentName(authHeader);
|
||||||
authHeader,
|
return this.missedQueue.getMissedQueue(agentName, authHeader);
|
||||||
);
|
}
|
||||||
const name = data.currentUser?.workspaceMember?.name;
|
|
||||||
const full = `${name?.firstName ?? ''} ${name?.lastName ?? ''}`.trim();
|
@Patch('missed-queue/:id/status')
|
||||||
if (full) return full;
|
async updateMissedCallStatus(
|
||||||
} catch (err) {
|
@Param('id') id: string,
|
||||||
this.logger.warn(`Failed to resolve agent name: ${err}`);
|
@Headers('authorization') authHeader: string,
|
||||||
}
|
@Body() body: { status: string },
|
||||||
throw new HttpException('Could not determine agent identity', 400);
|
) {
|
||||||
|
if (!authHeader)
|
||||||
|
throw new HttpException('Authorization header required', 401);
|
||||||
|
if (!body.status) throw new HttpException('status is required', 400);
|
||||||
|
return this.missedQueue.updateStatus(id, body.status, authHeader);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveAgentName(authHeader: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const data = await this.platform.queryWithAuth<any>(
|
||||||
|
`{ currentUser { workspaceMember { name { firstName lastName } } } }`,
|
||||||
|
undefined,
|
||||||
|
authHeader,
|
||||||
|
);
|
||||||
|
const name = data.currentUser?.workspaceMember?.name;
|
||||||
|
const full = `${name?.firstName ?? ''} ${name?.lastName ?? ''}`.trim();
|
||||||
|
if (full) return full;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to resolve agent name: ${err}`);
|
||||||
}
|
}
|
||||||
|
throw new HttpException('Could not determine agent identity', 400);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,13 @@ import { MissedCallWebhookController } from './missed-call-webhook.controller';
|
|||||||
import { KookooCallbackController } from './kookoo-callback.controller';
|
import { KookooCallbackController } from './kookoo-callback.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule)],
|
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule)],
|
||||||
controllers: [WorklistController, MissedCallWebhookController, KookooCallbackController],
|
controllers: [
|
||||||
providers: [WorklistService, MissedQueueService],
|
WorklistController,
|
||||||
exports: [MissedQueueService],
|
MissedCallWebhookController,
|
||||||
|
KookooCallbackController,
|
||||||
|
],
|
||||||
|
providers: [WorklistService, MissedQueueService],
|
||||||
|
exports: [MissedQueueService],
|
||||||
})
|
})
|
||||||
export class WorklistModule {}
|
export class WorklistModule {}
|
||||||
|
|||||||
@@ -2,37 +2,44 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
|
||||||
export type WorklistResponse = {
|
export type WorklistResponse = {
|
||||||
missedCalls: any[];
|
missedCalls: any[];
|
||||||
followUps: any[];
|
followUps: any[];
|
||||||
marketingLeads: any[];
|
marketingLeads: any[];
|
||||||
totalPending: number;
|
totalPending: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WorklistService {
|
export class WorklistService {
|
||||||
private readonly logger = new Logger(WorklistService.name);
|
private readonly logger = new Logger(WorklistService.name);
|
||||||
|
|
||||||
constructor(private readonly platform: PlatformGraphqlService) {}
|
constructor(private readonly platform: PlatformGraphqlService) {}
|
||||||
|
|
||||||
async getWorklist(agentName: string, authHeader: string): Promise<WorklistResponse> {
|
async getWorklist(
|
||||||
const [missedCalls, followUps, marketingLeads] = await Promise.all([
|
agentName: string,
|
||||||
this.getMissedCalls(agentName, authHeader),
|
authHeader: string,
|
||||||
this.getPendingFollowUps(agentName, authHeader),
|
): Promise<WorklistResponse> {
|
||||||
this.getAssignedLeads(agentName, authHeader),
|
const [missedCalls, followUps, marketingLeads] = await Promise.all([
|
||||||
]);
|
this.getMissedCalls(agentName, authHeader),
|
||||||
|
this.getPendingFollowUps(agentName, authHeader),
|
||||||
|
this.getAssignedLeads(agentName, authHeader),
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
missedCalls,
|
missedCalls,
|
||||||
followUps,
|
followUps,
|
||||||
marketingLeads,
|
marketingLeads,
|
||||||
totalPending: missedCalls.length + followUps.length + marketingLeads.length,
|
totalPending:
|
||||||
};
|
missedCalls.length + followUps.length + marketingLeads.length,
|
||||||
}
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async getAssignedLeads(agentName: string, authHeader: string): Promise<any[]> {
|
private async getAssignedLeads(
|
||||||
try {
|
agentName: string,
|
||||||
const data = await this.platform.queryWithAuth<any>(
|
authHeader: string,
|
||||||
`{ leads(first: 20, filter: { assignedAgent: { eq: "${agentName}" } }, orderBy: [{ createdAt: AscNullsLast }]) { edges { node {
|
): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
const data = await this.platform.queryWithAuth<any>(
|
||||||
|
`{ leads(first: 20, filter: { assignedAgent: { eq: "${agentName}" } }, orderBy: [{ createdAt: AscNullsLast }]) { edges { node {
|
||||||
id createdAt
|
id createdAt
|
||||||
contactName { firstName lastName }
|
contactName { firstName lastName }
|
||||||
contactPhone { primaryPhoneNumber }
|
contactPhone { primaryPhoneNumber }
|
||||||
@@ -42,43 +49,49 @@ export class WorklistService {
|
|||||||
contactAttempts spamScore isSpam
|
contactAttempts spamScore isSpam
|
||||||
aiSummary aiSuggestedAction
|
aiSummary aiSuggestedAction
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined,
|
undefined,
|
||||||
authHeader,
|
authHeader,
|
||||||
);
|
);
|
||||||
return data.leads.edges.map((e: any) => e.node);
|
return data.leads.edges.map((e: any) => e.node);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Failed to fetch assigned leads: ${err}`);
|
this.logger.warn(`Failed to fetch assigned leads: ${err}`);
|
||||||
return [];
|
return [];
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async getPendingFollowUps(agentName: string, authHeader: string): Promise<any[]> {
|
private async getPendingFollowUps(
|
||||||
try {
|
agentName: string,
|
||||||
const data = await this.platform.queryWithAuth<any>(
|
authHeader: string,
|
||||||
`{ followUps(first: 20, filter: { assignedAgent: { eq: "${agentName}" } }) { edges { node {
|
): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
const data = await this.platform.queryWithAuth<any>(
|
||||||
|
`{ followUps(first: 20, filter: { assignedAgent: { eq: "${agentName}" } }) { edges { node {
|
||||||
id name createdAt
|
id name createdAt
|
||||||
typeCustom status scheduledAt completedAt
|
typeCustom status scheduledAt completedAt
|
||||||
priority assignedAgent
|
priority assignedAgent
|
||||||
patientId
|
patientId
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined,
|
undefined,
|
||||||
authHeader,
|
authHeader,
|
||||||
);
|
);
|
||||||
// Filter to PENDING/OVERDUE client-side since platform may not support in-filter on remapped fields
|
// Filter to PENDING/OVERDUE client-side since platform may not support in-filter on remapped fields
|
||||||
return data.followUps.edges
|
return data.followUps.edges
|
||||||
.map((e: any) => e.node)
|
.map((e: any) => e.node)
|
||||||
.filter((f: any) => f.status === 'PENDING' || f.status === 'OVERDUE');
|
.filter((f: any) => f.status === 'PENDING' || f.status === 'OVERDUE');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Failed to fetch follow-ups: ${err}`);
|
this.logger.warn(`Failed to fetch follow-ups: ${err}`);
|
||||||
return [];
|
return [];
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async getMissedCalls(agentName: string, authHeader: string): Promise<any[]> {
|
private async getMissedCalls(
|
||||||
try {
|
agentName: string,
|
||||||
// FIFO ordering (AscNullsLast) — oldest first. Filter to active callback statuses only.
|
authHeader: string,
|
||||||
const data = await this.platform.queryWithAuth<any>(
|
): Promise<any[]> {
|
||||||
`{ calls(first: 20, filter: { agentName: { eq: "${agentName}" }, callStatus: { eq: MISSED }, callbackstatus: { in: [PENDING_CALLBACK, CALLBACK_ATTEMPTED] } }, orderBy: [{ startedAt: AscNullsLast }]) { edges { node {
|
try {
|
||||||
|
// FIFO ordering (AscNullsLast) — oldest first. Filter to active callback statuses only.
|
||||||
|
const data = await this.platform.queryWithAuth<any>(
|
||||||
|
`{ calls(first: 20, filter: { agentName: { eq: "${agentName}" }, callStatus: { eq: MISSED }, callbackstatus: { in: [PENDING_CALLBACK, CALLBACK_ATTEMPTED] } }, orderBy: [{ startedAt: AscNullsLast }]) { edges { node {
|
||||||
id name createdAt
|
id name createdAt
|
||||||
direction callStatus agentName
|
direction callStatus agentName
|
||||||
callerNumber { primaryPhoneNumber }
|
callerNumber { primaryPhoneNumber }
|
||||||
@@ -86,13 +99,13 @@ export class WorklistService {
|
|||||||
disposition leadId
|
disposition leadId
|
||||||
callbackstatus callsourcenumber missedcallcount callbackattemptedat
|
callbackstatus callsourcenumber missedcallcount callbackattemptedat
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined,
|
undefined,
|
||||||
authHeader,
|
authHeader,
|
||||||
);
|
);
|
||||||
return data.calls.edges.map((e: any) => e.node);
|
return data.calls.edges.map((e: any) => e.node);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Failed to fetch missed calls: ${err}`);
|
this.logger.warn(`Failed to fetch missed calls: ${err}`);
|
||||||
return [];
|
return [];
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user