lint and format

This commit is contained in:
Kartik Datrika
2026-03-23 15:46:32 +05:30
parent 30a4cda178
commit a1157ab4c1
48 changed files with 10980 additions and 2810 deletions

32
.claudeignore Normal file
View 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

View File

@@ -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
View File

@@ -0,0 +1 @@
npx lint-staged

View File

@@ -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
View File

@@ -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",

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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 ? `MonFri ${c.weekdayHours}` : '', .join(', ')
c.saturdayHours ? `Sat ${c.saturdayHours}` : '', : '';
c.sundayHours ? `Sun ${c.sundayHours}` : 'Sun closed', const hours = [
].filter(Boolean).join(', '); c.weekdayHours ? `MonFri ${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?';
}
} }

View File

@@ -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 };
}
} }

View File

@@ -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');
} }

View File

@@ -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 {}

View File

@@ -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 {}

View File

@@ -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);
}
} }
}
} }

View File

@@ -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 {}

View File

@@ -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);
}
} }
}
} }

View File

@@ -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 {}

View File

@@ -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 '';
}
} }
}
} }

View File

@@ -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}`);
} }
} }

View File

@@ -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 {}

View File

@@ -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}`,
);
}
}
} }
}
} }

View File

@@ -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;
}; };

View File

@@ -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,
};
}
} }

View File

@@ -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',
},
}); });

View File

@@ -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' };
}
} }

View File

@@ -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 {}

View File

@@ -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;
}
} }

View File

@@ -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;
}; };

View File

@@ -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);
}
}
} }
}
} }

View File

@@ -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 {}

View File

@@ -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 },
};
}
} }

View File

@@ -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 {}

View File

@@ -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();

View File

@@ -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>`;
}
} }

View File

@@ -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';
}
} }

View File

@@ -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 {}

View File

@@ -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;
} }
}
} }

View File

@@ -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);
} }
} }

View File

@@ -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 {}

View File

@@ -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;
}; };

View File

@@ -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: [] };
}
} }
}
} }

View File

@@ -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 {}

View File

@@ -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 };
}
}
} }

View File

@@ -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;
}
} }

View File

@@ -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: [] };
}
} }
}
} }

View File

@@ -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);
}
} }

View File

@@ -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 {}

View File

@@ -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 [];
}
} }
}
} }