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';
@@ -34,7 +41,10 @@ export class AiChatController {
} }
@Post('chat') @Post('chat')
async chat(@Body() body: ChatRequest, @Headers('authorization') auth: string) { async chat(
@Body() body: ChatRequest,
@Headers('authorization') auth: string,
) {
if (!auth) throw new HttpException('Authorization required', 401); if (!auth) throw new HttpException('Authorization required', 401);
if (!body.message?.trim()) throw new HttpException('message required', 400); if (!body.message?.trim()) throw new HttpException('message required', 400);
@@ -50,14 +60,22 @@ export class AiChatController {
} }
if (!this.aiModel) { if (!this.aiModel) {
return { reply: await this.fallback(msg, auth), sources: ['fallback'], confidence: 'low' }; return {
reply: await this.fallback(msg, auth),
sources: ['fallback'],
confidence: 'low',
};
} }
try { try {
return await this.chatWithTools(`${prefix}${msg}`, auth); return await this.chatWithTools(`${prefix}${msg}`, auth);
} catch (err) { } catch (err) {
this.logger.error(`AI chat error: ${err}`); this.logger.error(`AI chat error: ${err}`);
return { reply: await this.fallback(msg, auth), sources: ['fallback'], confidence: 'low' }; return {
reply: await this.fallback(msg, auth),
sources: ['fallback'],
confidence: 'low',
};
} }
} }
@@ -80,33 +98,45 @@ 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); const clinics = clinicData.clinics.edges.map((e: any) => e.node);
if (clinics.length) { if (clinics.length) {
sections.push('## Clinics'); sections.push('## Clinics');
for (const c of clinics) { for (const c of clinics) {
const addr = c.addressCustom const addr = c.addressCustom
? [c.addressCustom.addressStreet1, c.addressCustom.addressCity].filter(Boolean).join(', ') ? [c.addressCustom.addressStreet1, c.addressCustom.addressCity]
.filter(Boolean)
.join(', ')
: ''; : '';
const hours = [ const hours = [
c.weekdayHours ? `MonFri ${c.weekdayHours}` : '', c.weekdayHours ? `MonFri ${c.weekdayHours}` : '',
c.saturdayHours ? `Sat ${c.saturdayHours}` : '', c.saturdayHours ? `Sat ${c.saturdayHours}` : '',
c.sundayHours ? `Sun ${c.sundayHours}` : 'Sun closed', c.sundayHours ? `Sun ${c.sundayHours}` : 'Sun closed',
].filter(Boolean).join(', '); ]
.filter(Boolean)
.join(', ');
sections.push(`- ${c.clinicName ?? c.name}: ${addr}. ${hours}.`); sections.push(`- ${c.clinicName ?? c.name}: ${addr}. ${hours}.`);
} }
const rulesClinic = clinics[0]; const rulesClinic = clinics[0];
const rules: string[] = []; const rules: string[] = [];
if (rulesClinic.cancellationWindowHours) rules.push(`Free cancellation up to ${rulesClinic.cancellationWindowHours}h before`); if (rulesClinic.cancellationWindowHours)
if (rulesClinic.arriveEarlyMin) rules.push(`Arrive ${rulesClinic.arriveEarlyMin}min early`); rules.push(
if (rulesClinic.requiredDocuments) rules.push(`First-time patients bring ${rulesClinic.requiredDocuments}`); `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.walkInAllowed) rules.push('Walk-ins accepted');
if (rulesClinic.onlineBooking) rules.push('Online booking available'); if (rulesClinic.onlineBooking) rules.push('Online booking available');
if (rules.length) { if (rules.length) {
sections.push('\n### Booking Rules'); sections.push('\n### Booking Rules');
sections.push(rules.map(r => `- ${r}`).join('\n')); sections.push(rules.map((r) => `- ${r}`).join('\n'));
} }
const payments: string[] = []; const payments: string[] = [];
@@ -131,14 +161,17 @@ export class AiChatController {
department inclusions durationMin eligibility department inclusions durationMin eligibility
packageTests { edges { node { labTest { testName category } order } } } packageTests { edges { node { labTest { testName category } order } } }
} } } }`, } } } }`,
undefined, auth, undefined,
auth,
); );
const packages = pkgData.healthPackages.edges.map((e: any) => e.node); const packages = pkgData.healthPackages.edges.map((e: any) => e.node);
if (packages.length) { if (packages.length) {
sections.push('\n## Health Packages'); sections.push('\n## Health Packages');
for (const p of packages) { for (const p of packages) {
const price = p.price ? `${p.price.amountMicros / 1_000_000}` : ''; const price = p.price ? `${p.price.amountMicros / 1_000_000}` : '';
const disc = p.discountedPrice?.amountMicros ? ` (discounted: ₹${p.discountedPrice.amountMicros / 1_000_000})` : ''; const disc = p.discountedPrice?.amountMicros
? ` (discounted: ₹${p.discountedPrice.amountMicros / 1_000_000})`
: '';
const dept = p.department ? ` [${p.department}]` : ''; const dept = p.department ? ` [${p.department}]` : '';
sections.push(`- ${p.packageName ?? p.name}: ${price}${disc}${dept}`); sections.push(`- ${p.packageName ?? p.name}: ${price}${disc}${dept}`);
const tests = p.packageTests?.edges const tests = p.packageTests?.edges
@@ -162,13 +195,16 @@ export class AiChatController {
`{ insurancePartners(first: 30, filter: { empanelmentStatus: { eq: ACTIVE } }) { edges { node { `{ insurancePartners(first: 30, filter: { empanelmentStatus: { eq: ACTIVE } }) { edges { node {
id name insurerName tpaName settlementType planTypesAccepted id name insurerName tpaName settlementType planTypesAccepted
} } } }`, } } } }`,
undefined, auth, undefined,
auth,
); );
const insurers = insData.insurancePartners.edges.map((e: any) => e.node); const insurers = insData.insurancePartners.edges.map((e: any) => e.node);
if (insurers.length) { if (insurers.length) {
sections.push('\n## Insurance Partners'); sections.push('\n## Insurance Partners');
const names = insurers.map((i: any) => { const names = insurers.map((i: any) => {
const settlement = i.settlementType ? ` (${i.settlementType.toLowerCase()})` : ''; const settlement = i.settlementType
? ` (${i.settlementType.toLowerCase()})`
: '';
return `${i.insurerName ?? i.name}${settlement}`; return `${i.insurerName ?? i.name}${settlement}`;
}); });
sections.push(names.join(', ')); sections.push(names.join(', '));
@@ -177,9 +213,12 @@ export class AiChatController {
this.logger.warn(`Failed to fetch insurance partners: ${err}`); this.logger.warn(`Failed to fetch insurance partners: ${err}`);
} }
this.knowledgeBase = sections.join('\n') || 'No hospital information available yet.'; this.knowledgeBase =
sections.join('\n') || 'No hospital information available yet.';
this.kbLoadedAt = now; this.kbLoadedAt = now;
this.logger.log(`Knowledge base built (${this.knowledgeBase.length} chars)`); this.logger.log(
`Knowledge base built (${this.knowledgeBase.length} chars)`,
);
return this.knowledgeBase; return this.knowledgeBase;
} }
@@ -212,7 +251,8 @@ ${kb}`;
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:
'Search for a patient/lead by phone number or name. Returns their profile, lead status, AI summary, and linked patient/campaign IDs.',
inputSchema: z.object({ inputSchema: z.object({
phone: z.string().optional().describe('Phone number to search'), phone: z.string().optional().describe('Phone number to search'),
name: z.string().optional().describe('Patient/lead name to search'), name: z.string().optional().describe('Patient/lead name to search'),
@@ -227,7 +267,8 @@ ${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 leads = data.leads.edges.map((e: any) => e.node);
const phoneClean = (phone ?? '').replace(/\D/g, ''); const phoneClean = (phone ?? '').replace(/\D/g, '');
@@ -235,23 +276,30 @@ ${kb}`;
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 (lp.endsWith(phoneClean) || phoneClean.endsWith(lp))
return true;
} }
if (nameClean) { if (nameClean) {
const fn = `${l.contactName?.firstName ?? ''} ${l.contactName?.lastName ?? ''}`.toLowerCase(); const fn =
`${l.contactName?.firstName ?? ''} ${l.contactName?.lastName ?? ''}`.toLowerCase();
if (fn.includes(nameClean)) return true; if (fn.includes(nameClean)) return true;
} }
return false; return false;
}); });
if (!matched.length) return { found: false, message: 'No patient/lead found.' }; if (!matched.length)
return { found: false, message: 'No patient/lead found.' };
return { found: true, count: matched.length, leads: matched }; return { found: true, count: matched.length, leads: matched };
}, },
}), }),
lookup_appointments: tool({ lookup_appointments: tool({
description: 'Get all appointments (past and upcoming) for a patient. Returns doctor, department, date, status, and reason.', description:
'Get all appointments (past and upcoming) for a patient. Returns doctor, department, date, status, and reason.',
inputSchema: z.object({ inputSchema: z.object({
patientId: z.string().describe('Patient ID'), patientId: z.string().describe('Patient ID'),
}), }),
@@ -261,14 +309,18 @@ ${kb}`;
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:
'Get call log for a lead — all inbound/outbound calls with dispositions and durations.',
inputSchema: z.object({ inputSchema: z.object({
leadId: z.string().describe('Lead ID'), leadId: z.string().describe('Lead ID'),
}), }),
@@ -277,14 +329,16 @@ ${kb}`;
`{ calls(first: 20, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { `{ calls(first: 20, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
id name direction callStatus agentName startedAt durationSec disposition id name direction callStatus agentName startedAt durationSec disposition
} } } }`, } } } }`,
undefined, 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:
'Get the full interaction timeline — status changes, calls, WhatsApp, notes, appointments.',
inputSchema: z.object({ inputSchema: z.object({
leadId: z.string().describe('Lead ID'), leadId: z.string().describe('Lead ID'),
}), }),
@@ -293,16 +347,22 @@ ${kb}`;
`{ leadActivities(first: 30, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ occurredAt: DescNullsLast }]) { edges { node { `{ leadActivities(first: 30, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ occurredAt: DescNullsLast }]) { edges { node {
id activityType summary occurredAt performedBy channel id activityType summary occurredAt performedBy channel
} } } }`, } } } }`,
undefined, 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:
'Get doctor details — schedule, clinic, fees, qualifications, specialty. Search by name.',
inputSchema: z.object({ inputSchema: z.object({
doctorName: z.string().describe('Doctor name (e.g. "Patel", "Sharma")'), doctorName: z
.string()
.describe('Doctor name (e.g. "Patel", "Sharma")'),
}), }),
execute: async ({ doctorName }) => { execute: async ({ doctorName }) => {
const data = await platformService.queryWithAuth<any>( const data = await platformService.queryWithAuth<any>(
@@ -315,25 +375,35 @@ ${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 =
`${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''} ${d.name ?? ''}`.toLowerCase();
return full.includes(search); 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',
})), })),
}; };
}, },
@@ -341,12 +411,15 @@ ${kb}`;
}, },
}); });
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:
toolCallCount > 0 ? ['platform_db', 'hospital_kb'] : ['hospital_kb'],
confidence: 'high', confidence: 'high',
}; };
} }
@@ -359,37 +432,62 @@ ${kb}`;
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 docs = doctors.doctors.edges.map((e: any) => e.node);
const l = msg.toLowerCase(); const l = msg.toLowerCase();
const matchedDoc = docs.find((d: any) => { const matchedDoc = docs.find((d: any) => {
const full = `${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
.split(/\s+/)
.some((w: string) => w.length > 2 && full.includes(w));
}); });
if (matchedDoc) { if (matchedDoc) {
const fee = matchedDoc.consultationFeeNew ? `${matchedDoc.consultationFeeNew.amountMicros / 1_000_000}` : ''; const fee = matchedDoc.consultationFeeNew
? `${matchedDoc.consultationFeeNew.amountMicros / 1_000_000}`
: '';
const clinic = matchedDoc.clinic?.clinicName ?? ''; const clinic = matchedDoc.clinic?.clinicName ?? '';
return `Dr. ${matchedDoc.fullName?.lastName ?? matchedDoc.name} (${matchedDoc.department ?? matchedDoc.specialty}): ${matchedDoc.visitingHours ?? 'hours not set'}${clinic ? ` at ${clinic}` : ''}${fee ? `. Fee: ${fee}` : ''}.`; return `Dr. ${matchedDoc.fullName?.lastName ?? matchedDoc.name} (${matchedDoc.department ?? matchedDoc.specialty}): ${matchedDoc.visitingHours ?? 'hours not set'}${clinic ? ` at ${clinic}` : ''}${fee ? `. Fee: ${fee}` : ''}.`;
} }
if (l.includes('doctor') || l.includes('available')) { if (l.includes('doctor') || l.includes('available')) {
return '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 (
l.includes('package') ||
l.includes('checkup') ||
l.includes('screening')
) {
const pkgs = await this.platform.queryWithAuth<any>( const pkgs = await this.platform.queryWithAuth<any>(
`{ healthPackages(first: 20) { edges { node { packageName price { amountMicros } } } } }`, `{ healthPackages(first: 20) { edges { node { packageName price { amountMicros } } } } }`,
undefined, auth, undefined,
auth,
); );
const packages = pkgs.healthPackages.edges.map((e: any) => e.node); const packages = pkgs.healthPackages.edges.map((e: any) => e.node);
if (packages.length) { if (packages.length) {
return 'Packages: ' + packages.map((p: any) => return (
`${p.packageName}${p.price?.amountMicros ? p.price.amountMicros / 1_000_000 : 'N/A'}` 'Packages: ' +
).join(' | ') + '.'; packages
.map(
(p: any) =>
`${p.packageName}${p.price?.amountMicros ? p.price.amountMicros / 1_000_000 : 'N/A'}`,
)
.join(' | ') +
'.'
);
} }
} }
} catch { } catch {

View File

@@ -23,8 +23,12 @@ type EnrichmentResult = {
}; };
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()
@@ -46,15 +50,20 @@ export class AiEnrichmentService {
try { try {
const daysSince = lead.createdAt const daysSince = lead.createdAt
? Math.floor((Date.now() - new Date(lead.createdAt).getTime()) / (1000 * 60 * 60 * 24)) ? Math.floor(
(Date.now() - new Date(lead.createdAt).getTime()) /
(1000 * 60 * 60 * 24),
)
: 0; : 0;
const activitiesText = lead.activities?.length const activitiesText = lead.activities?.length
? lead.activities.map(a => `- ${a.activityType}: ${a.summary}`).join('\n') ? lead.activities
.map((a) => `- ${a.activityType}: ${a.summary}`)
.join('\n')
: 'No previous interactions'; : 'No previous interactions';
const { object } = await generateObject({ const { object } = await generateObject({
model: this.aiModel!, model: this.aiModel,
schema: enrichmentSchema, schema: enrichmentSchema,
prompt: `You are an AI assistant for a hospital call center. 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.
@@ -71,7 +80,9 @@ Recent activity:
${activitiesText}`, ${activitiesText}`,
}); });
this.logger.log(`AI enrichment generated for lead ${lead.firstName} ${lead.lastName}`); this.logger.log(
`AI enrichment generated for lead ${lead.firstName} ${lead.lastName}`,
);
return object; return object;
} catch (error) { } catch (error) {
this.logger.error(`AI enrichment failed: ${error}`); this.logger.error(`AI enrichment failed: ${error}`);
@@ -81,12 +92,16 @@ ${activitiesText}`,
private fallbackEnrichment(lead: LeadContext): EnrichmentResult { private fallbackEnrichment(lead: LeadContext): EnrichmentResult {
const daysSince = lead.createdAt const daysSince = lead.createdAt
? Math.floor((Date.now() - new Date(lead.createdAt).getTime()) / (1000 * 60 * 60 * 24)) ? Math.floor(
(Date.now() - new Date(lead.createdAt).getTime()) /
(1000 * 60 * 60 * 24),
)
: 0; : 0;
const attempts = lead.contactAttempts ?? 0; const attempts = lead.contactAttempts ?? 0;
const service = lead.interestedService ?? 'general inquiry'; const service = lead.interestedService ?? 'general inquiry';
const source = lead.leadSource?.replace(/_/g, ' ').toLowerCase() ?? 'unknown source'; const source =
lead.leadSource?.replace(/_/g, ' ').toLowerCase() ?? 'unknown source';
let summary: string; let summary: string;
let action: string; let action: string;

View File

@@ -21,6 +21,7 @@ export function createAiModel(config: ConfigService): LanguageModel | null {
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.anthropicApiKey');
return !!config.get<string>('ai.openaiApiKey'); return !!config.get<string>('ai.openaiApiKey');
} }

View File

@@ -15,8 +15,10 @@ export class AuthController {
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')
@@ -25,7 +27,9 @@ export class AuthController {
try { try {
// Step 1: Get login token // Step 1: Get login token
const loginRes = await axios.post(this.graphqlUrl, { const loginRes = await axios.post(
this.graphqlUrl,
{
query: `mutation GetLoginToken($email: String!, $password: String!) { query: `mutation GetLoginToken($email: String!, $password: String!) {
getLoginTokenFromCredentials( getLoginTokenFromCredentials(
email: $email email: $email
@@ -36,12 +40,14 @@ export class AuthController {
} }
}`, }`,
variables: { email: body.email, password: body.password }, variables: { email: body.email, password: body.password },
}, { },
{
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Workspace-Subdomain': this.workspaceSubdomain, 'X-Workspace-Subdomain': this.workspaceSubdomain,
}, },
}); },
);
if (loginRes.data.errors) { if (loginRes.data.errors) {
throw new HttpException( throw new HttpException(
@@ -50,10 +56,13 @@ export class AuthController {
); );
} }
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(
this.graphqlUrl,
{
query: `mutation GetAuthTokens($loginToken: String!) { query: `mutation GetAuthTokens($loginToken: String!) {
getAuthTokensFromLoginToken( getAuthTokensFromLoginToken(
loginToken: $loginToken loginToken: $loginToken
@@ -66,12 +75,14 @@ export class AuthController {
} }
}`, }`,
variables: { loginToken }, variables: { loginToken },
}, { },
{
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Workspace-Subdomain': this.workspaceSubdomain, 'X-Workspace-Subdomain': this.workspaceSubdomain,
}, },
}); },
);
if (tokenRes.data.errors) { if (tokenRes.data.errors) {
throw new HttpException( throw new HttpException(
@@ -84,14 +95,18 @@ export class AuthController {
const accessToken = tokens.accessOrWorkspaceAgnosticToken.token; const accessToken = tokens.accessOrWorkspaceAgnosticToken.token;
// Step 3: Fetch user profile with roles // Step 3: Fetch user profile with roles
const profileRes = await axios.post(this.graphqlUrl, { const profileRes = await axios.post(
this.graphqlUrl,
{
query: `{ currentUser { id email workspaceMember { id name { firstName lastName } userEmail avatarUrl roles { id label } } } }`, query: `{ currentUser { id email workspaceMember { id name { firstName lastName } userEmail avatarUrl roles { id label } } } }`,
}, { },
{
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
}, },
}); },
);
const currentUser = profileRes.data?.data?.currentUser; const currentUser = profileRes.data?.data?.currentUser;
const workspaceMember = currentUser?.workspaceMember; const workspaceMember = currentUser?.workspaceMember;
@@ -109,21 +124,28 @@ export class AuthController {
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 =
process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$';
const ozSipId = process.env.OZONETEL_SIP_ID ?? '521814'; const ozSipId = process.env.OZONETEL_SIP_ID ?? '521814';
this.ozonetelAgent.loginAgent({ this.ozonetelAgent
.loginAgent({
agentId: ozAgentId, agentId: ozAgentId,
password: ozAgentPassword, password: ozAgentPassword,
phoneNumber: ozSipId, phoneNumber: ozSipId,
mode: 'blended', mode: 'blended',
}).catch(err => { })
this.logger.warn(`Ozonetel agent login failed (non-blocking): ${err.message}`); .catch((err) => {
this.logger.warn(
`Ozonetel agent login failed (non-blocking): ${err.message}`,
);
}); });
} }
@@ -156,7 +178,9 @@ export class AuthController {
this.logger.log('Token refresh request'); this.logger.log('Token refresh request');
try { try {
const res = await axios.post(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 {
@@ -166,12 +190,16 @@ 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(
`Token refresh failed: ${res.data.errors[0]?.message}`,
);
throw new HttpException('Token refresh failed', 401); throw new HttpException('Token refresh failed', 401);
} }

View File

@@ -33,15 +33,20 @@ export class CallAssistGateway implements OnGatewayDisconnect {
@SubscribeMessage('call-assist:start') @SubscribeMessage('call-assist:start')
async handleStart( async handleStart(
@ConnectedSocket() client: Socket, @ConnectedSocket() client: Socket,
@MessageBody() data: { ucid: string; leadId?: string; callerPhone?: string }, @MessageBody()
data: { ucid: string; leadId?: string; callerPhone?: string },
) { ) {
this.logger.log(`Call assist start: ucid=${data.ucid} lead=${data.leadId ?? 'none'}`); this.logger.log(
`Call assist start: ucid=${data.ucid} lead=${data.leadId ?? 'none'}`,
);
const context = await this.callAssist.loadCallContext( const context = await this.callAssist.loadCallContext(
data.leadId ?? null, data.leadId ?? null,
data.callerPhone ?? null, data.callerPhone ?? null,
); );
client.emit('call-assist:context', { context: context.substring(0, 200) + '...' }); client.emit('call-assist:context', {
context: context.substring(0, 200) + '...',
});
const session: SessionState = { const session: SessionState = {
deepgramWs: null, deepgramWs: null,
@@ -87,13 +92,18 @@ export class CallAssistGateway implements OnGatewayDisconnect {
session.deepgramWs = dgWs; session.deepgramWs = dgWs;
} else { } else {
this.logger.warn('DEEPGRAM_API_KEY not set — transcription disabled'); this.logger.warn('DEEPGRAM_API_KEY not set — transcription disabled');
client.emit('call-assist:error', { message: 'Transcription not configured' }); client.emit('call-assist:error', {
message: 'Transcription not configured',
});
} }
// AI suggestion every 10 seconds // AI suggestion every 10 seconds
session.suggestionTimer = setInterval(async () => { session.suggestionTimer = setInterval(async () => {
if (!session.transcript.trim()) return; if (!session.transcript.trim()) return;
const suggestion = await this.callAssist.getSuggestion(session.transcript, session.context); const suggestion = await this.callAssist.getSuggestion(
session.transcript,
session.context,
);
if (suggestion) { if (suggestion) {
client.emit('call-assist:suggestion', { text: suggestion }); client.emit('call-assist:suggestion', { text: suggestion });
} }
@@ -128,7 +138,9 @@ export class CallAssistGateway implements OnGatewayDisconnect {
if (session) { if (session) {
if (session.suggestionTimer) clearInterval(session.suggestionTimer); if (session.suggestionTimer) clearInterval(session.suggestionTimer);
if (session.deepgramWs) { if (session.deepgramWs) {
try { session.deepgramWs.close(); } catch {} try {
session.deepgramWs.close();
} catch {}
} }
this.sessions.delete(clientId); this.sessions.delete(clientId);
} }

View File

@@ -19,8 +19,13 @@ export class CallAssistService {
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,
callerPhone: string | null,
): Promise<string> {
const authHeader = this.platformApiKey
? `Bearer ${this.platformApiKey}`
: '';
if (!authHeader) return 'No platform context available.'; if (!authHeader) return 'No platform context available.';
try { try {
@@ -35,7 +40,8 @@ export class CallAssistService {
lastContacted contactAttempts lastContacted contactAttempts
aiSummary aiSuggestedAction aiSummary aiSuggestedAction
} } } }`, } } } }`,
undefined, authHeader, undefined,
authHeader,
); );
const lead = leadResult.leads.edges[0]?.node; const lead = leadResult.leads.edges[0]?.node;
if (lead) { if (lead) {
@@ -43,9 +49,13 @@ export class CallAssistService {
? `${lead.contactName.firstName} ${lead.contactName.lastName}`.trim() ? `${lead.contactName.firstName} ${lead.contactName.lastName}`.trim()
: lead.name; : lead.name;
parts.push(`CALLER: ${name}`); parts.push(`CALLER: ${name}`);
parts.push(`Phone: ${lead.contactPhone?.primaryPhoneNumber ?? callerPhone}`); parts.push(
`Phone: ${lead.contactPhone?.primaryPhoneNumber ?? callerPhone}`,
);
parts.push(`Source: ${lead.source ?? 'Unknown'}`); parts.push(`Source: ${lead.source ?? 'Unknown'}`);
parts.push(`Interested in: ${lead.interestedService ?? 'Not specified'}`); parts.push(
`Interested in: ${lead.interestedService ?? 'Not specified'}`,
);
parts.push(`Contact attempts: ${lead.contactAttempts ?? 0}`); parts.push(`Contact attempts: ${lead.contactAttempts ?? 0}`);
if (lead.aiSummary) parts.push(`AI Summary: ${lead.aiSummary}`); if (lead.aiSummary) parts.push(`AI Summary: ${lead.aiSummary}`);
} }
@@ -54,7 +64,8 @@ export class CallAssistService {
`{ 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 const appts = apptResult.appointments.edges
.map((e: any) => e.node) .map((e: any) => e.node)
@@ -62,8 +73,12 @@ export class CallAssistService {
if (appts.length > 0) { if (appts.length > 0) {
parts.push('\nPAST APPOINTMENTS:'); parts.push('\nPAST APPOINTMENTS:');
for (const a of appts) { for (const a of appts) {
const date = a.scheduledAt ? new Date(a.scheduledAt).toLocaleDateString('en-IN') : '?'; const date = a.scheduledAt
parts.push(`- ${date}: ${a.doctorName ?? '?'} (${a.department ?? '?'}) — ${a.appointmentStatus}`); ? new Date(a.scheduledAt).toLocaleDateString('en-IN')
: '?';
parts.push(
`- ${date}: ${a.doctorName ?? '?'} (${a.department ?? '?'}) — ${a.appointmentStatus}`,
);
} }
} }
} else if (callerPhone) { } else if (callerPhone) {
@@ -75,14 +90,19 @@ export class CallAssistService {
`{ 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); const docs = docResult.doctors.edges.map((e: any) => e.node);
if (docs.length > 0) { if (docs.length > 0) {
parts.push('\nAVAILABLE DOCTORS:'); parts.push('\nAVAILABLE DOCTORS:');
for (const d of docs) { for (const d of docs) {
const name = d.fullName ? `Dr. ${d.fullName.firstName} ${d.fullName.lastName}`.trim() : 'Unknown'; const name = d.fullName
parts.push(`- ${name}${d.department ?? '?'} ${d.clinic?.clinicName ?? '?'}`); ? `Dr. ${d.fullName.firstName} ${d.fullName.lastName}`.trim()
: 'Unknown';
parts.push(
`- ${name}${d.department ?? '?'}${d.clinic?.clinicName ?? '?'}`,
);
} }
} }

View File

@@ -7,7 +7,10 @@ import {
} from '@nestjs/websockets'; } from '@nestjs/websockets';
import { Logger, Inject, forwardRef } from '@nestjs/common'; import { Logger, Inject, forwardRef } from '@nestjs/common';
import { Server, Socket } from 'socket.io'; import { Server, Socket } from 'socket.io';
import type { 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({

View File

@@ -43,9 +43,7 @@ export class CallEventsService {
`Matched lead: ${lead.contactName?.firstName} ${lead.contactName?.lastName} (${lead.id})`, `Matched lead: ${lead.contactName?.firstName} ${lead.contactName?.lastName} (${lead.id})`,
); );
} else { } else {
this.logger.log( this.logger.log(`No lead found for phone ${callEvent.callerPhone}`);
`No lead found for phone ${callEvent.callerPhone}`,
);
} }
} catch (error) { } catch (error) {
this.logger.error(`Lead lookup failed: ${error}`); this.logger.error(`Lead lookup failed: ${error}`);
@@ -54,10 +52,7 @@ export class CallEventsService {
// 2. AI enrichment (if lead found and no existing summary) // 2. AI enrichment (if lead found and no existing summary)
if (lead && !lead.aiSummary) { if (lead && !lead.aiSummary) {
try { try {
const activities = await this.platform.getLeadActivities( const activities = await this.platform.getLeadActivities(lead.id, 5);
lead.id,
5,
);
const enrichment = await this.ai.enrichLead({ const enrichment = await this.ai.enrichLead({
firstName: lead.contactName?.firstName, firstName: lead.contactName?.firstName,
lastName: lead.contactName?.lastName, lastName: lead.contactName?.lastName,
@@ -92,10 +87,7 @@ export class CallEventsService {
}[] = []; }[] = [];
if (lead) { if (lead) {
try { try {
const activities = await this.platform.getLeadActivities( const activities = await this.platform.getLeadActivities(lead.id, 3);
lead.id,
3,
);
recentActivities = activities.map((a) => ({ recentActivities = activities.map((a) => ({
activityType: a.activityType ?? '', activityType: a.activityType ?? '',
summary: a.summary ?? '', summary: a.summary ?? '',
@@ -129,12 +121,10 @@ export class CallEventsService {
email: lead.contactEmail?.[0]?.address, email: lead.contactEmail?.[0]?.address,
source: lead.leadSource ?? undefined, source: lead.leadSource ?? undefined,
status: lead.leadStatus ?? undefined, status: lead.leadStatus ?? undefined,
interestedService: interestedService: lead.interestedService ?? undefined,
lead.interestedService ?? undefined,
age: daysSinceCreation, age: daysSinceCreation,
aiSummary: lead.aiSummary ?? undefined, aiSummary: lead.aiSummary ?? undefined,
aiSuggestedAction: aiSuggestedAction: lead.aiSuggestedAction ?? undefined,
lead.aiSuggestedAction ?? undefined,
recentActivities, recentActivities,
} }
: null, : null,
@@ -221,13 +211,9 @@ export class CallEventsService {
durationSeconds: payload.duration, durationSeconds: payload.duration,
leadId: payload.leadId, leadId: payload.leadId,
}); });
this.logger.log( this.logger.log(`Lead activity logged for ${payload.leadId}`);
`Lead activity logged for ${payload.leadId}`,
);
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(`Failed to create lead activity: ${error}`);
`Failed to create lead activity: ${error}`,
);
} }
} }
} }

View File

@@ -1,4 +1,11 @@
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';
@@ -33,11 +40,17 @@ export class CallLookupController {
} }
if (lead) { if (lead) {
this.logger.log(`Matched lead: ${lead.id}${lead.contactName?.firstName} ${lead.contactName?.lastName}`); this.logger.log(
`Matched lead: ${lead.id}${lead.contactName?.firstName} ${lead.contactName?.lastName}`,
);
// Get recent activities // Get recent activities
try { try {
activities = await this.platform.getLeadActivitiesWithToken(lead.id, authHeader, 5); activities = await this.platform.getLeadActivitiesWithToken(
lead.id,
authHeader,
5,
);
} catch (err) { } catch (err) {
this.logger.warn(`Activity fetch failed: ${err}`); this.logger.warn(`Activity fetch failed: ${err}`);
} }
@@ -64,10 +77,14 @@ export class CallLookupController {
// Persist AI enrichment back to platform // Persist AI enrichment back to platform
try { try {
await this.platform.updateLeadWithToken(lead.id, { await this.platform.updateLeadWithToken(
lead.id,
{
aiSummary: enrichment.aiSummary, aiSummary: enrichment.aiSummary,
aiSuggestedAction: enrichment.aiSuggestedAction, aiSuggestedAction: enrichment.aiSuggestedAction,
}, authHeader); },
authHeader,
);
} catch (err) { } catch (err) {
this.logger.warn(`Failed to persist AI enrichment: ${err}`); this.logger.warn(`Failed to persist AI enrichment: ${err}`);
} }

View File

@@ -2,7 +2,8 @@ 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:
process.env.PLATFORM_GRAPHQL_URL ?? 'http://localhost:4000/graphql',
apiKey: process.env.PLATFORM_API_KEY ?? '', apiKey: process.env.PLATFORM_API_KEY ?? '',
}, },
exotel: { exotel: {
@@ -13,7 +14,10 @@ export default () => ({
webhookSecret: process.env.EXOTEL_WEBHOOK_SECRET ?? '', webhookSecret: process.env.EXOTEL_WEBHOOK_SECRET ?? '',
}, },
missedQueue: { missedQueue: {
pollIntervalMs: parseInt(process.env.MISSED_QUEUE_POLL_INTERVAL_MS ?? '30000', 10), pollIntervalMs: parseInt(
process.env.MISSED_QUEUE_POLL_INTERVAL_MS ?? '30000',
10,
),
}, },
ai: { ai: {
provider: process.env.AI_PROVIDER ?? 'openai', provider: process.env.AI_PROVIDER ?? 'openai',

View File

@@ -15,7 +15,9 @@ export class ExotelController {
@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);

View File

@@ -8,8 +8,11 @@ export class ExotelService {
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'
? 'answered'
: event_details.event_type === 'terminal'
? 'ended'
: 'ringing'; : 'ringing';
const callEvent: CallEvent = { const callEvent: CallEvent = {
@@ -25,7 +28,9 @@ export class ExotelService {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
this.logger.log(`Parsed Exotel event: ${eventType} for call ${call_details.call_sid}`); this.logger.log(
`Parsed Exotel event: ${eventType} for call ${call_details.call_sid}`,
);
return callEvent; return callEvent;
} }
} }

View File

@@ -1,4 +1,11 @@
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';
@@ -21,16 +28,12 @@ export class GraphqlProxyController {
} }
try { try {
const response = await axios.post( const response = await axios.post(this.graphqlUrl, req.body, {
this.graphqlUrl,
req.body,
{
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': authHeader, Authorization: authHeader,
}, },
}, });
);
res.status(response.status).json(response.data); res.status(response.status).json(response.data);
} catch (error: any) { } catch (error: any) {

View File

@@ -18,10 +18,14 @@ export class HealthController {
try { try {
const start = Date.now(); const start = Date.now();
await axios.post(this.graphqlUrl, { query: '{ __typename }' }, { await axios.post(
this.graphqlUrl,
{ query: '{ __typename }' },
{
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
timeout: 5000, timeout: 5000,
}); },
);
platformLatency = Date.now() - start; platformLatency = Date.now() - start;
platformReachable = true; platformReachable = true;
} catch { } catch {

View File

@@ -1,6 +1,7 @@
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);
@@ -11,8 +12,21 @@ async function bootstrap() {
credentials: true, credentials: true,
}); });
const swaggerConfig = new DocumentBuilder()
.setTitle('Helix Engage Server')
.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'); const port = config.get('port');
await app.listen(port); await app.listen(port);
console.log(`Helix Engage Server running on port ${port}`); console.log(`Helix Engage Server running on port ${port}`);
console.log(`Swagger UI: http://localhost:${port}/api/docs`);
} }
bootstrap(); bootstrap();

View File

@@ -20,12 +20,16 @@ export class KookooIvrController {
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(
`Customer ${cid} answered — dialing DID ${this.callerId} to route to agent`,
);
return `<?xml version="1.0" encoding="UTF-8"?> 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>

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';
@@ -19,13 +27,20 @@ export class OzonetelAgentController {
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 =
config.get<string>('OZONETEL_AGENT_PASSWORD') ?? '';
this.defaultSipId = config.get<string>('OZONETEL_SIP_ID') ?? '521814'; this.defaultSipId = config.get<string>('OZONETEL_SIP_ID') ?? '521814';
} }
@Post('agent-login') @Post('agent-login')
async agentLogin( async agentLogin(
@Body() body: { agentId: string; password: string; phoneNumber: string; mode?: string }, @Body()
body: {
agentId: string;
password: string;
phoneNumber: string;
mode?: string;
},
) { ) {
this.logger.log(`Agent login request for ${body.agentId}`); this.logger.log(`Agent login request for ${body.agentId}`);
@@ -41,9 +56,7 @@ export class OzonetelAgentController {
} }
@Post('agent-logout') @Post('agent-logout')
async agentLogout( async agentLogout(@Body() body: { agentId: string; password: string }) {
@Body() body: { agentId: string; password: string },
) {
this.logger.log(`Agent logout request for ${body.agentId}`); this.logger.log(`Agent logout request for ${body.agentId}`);
try { try {
@@ -65,7 +78,9 @@ export class OzonetelAgentController {
throw new HttpException('state required', 400); throw new HttpException('state required', 400);
} }
this.logger.log(`Agent state change: ${this.defaultAgentId}${body.state} (${body.pauseReason ?? ''})`); this.logger.log(
`Agent state change: ${this.defaultAgentId}${body.state} (${body.pauseReason ?? ''})`,
);
try { try {
const result = await this.ozonetelAgent.changeAgentState({ const result = await this.ozonetelAgent.changeAgentState({
@@ -75,7 +90,8 @@ export class OzonetelAgentController {
}); });
return result; return result;
} catch (error: any) { } catch (error: any) {
const message = error.response?.data?.message ?? error.message ?? 'State change failed'; const message =
error.response?.data?.message ?? error.message ?? 'State change failed';
return { status: 'error', message }; return { status: 'error', message };
} }
@@ -84,7 +100,11 @@ export class OzonetelAgentController {
try { try {
const assigned = await this.missedQueue.assignNext(this.defaultAgentId); const assigned = await this.missedQueue.assignNext(this.defaultAgentId);
if (assigned) { if (assigned) {
return { status: 'ok', message: `State changed to Ready. Assigned missed call ${assigned.id}`, assignedCall: assigned }; return {
status: 'ok',
message: `State changed to Ready. Assigned missed call ${assigned.id}`,
assignedCall: assigned,
};
} }
} catch (err) { } catch (err) {
this.logger.warn(`Auto-assignment on Ready failed: ${err}`); this.logger.warn(`Auto-assignment on Ready failed: ${err}`);
@@ -94,7 +114,9 @@ export class OzonetelAgentController {
@Post('agent-ready') @Post('agent-ready')
async agentReady() { async agentReady() {
this.logger.log(`Force ready: logging out and back in agent ${this.defaultAgentId}`); this.logger.log(
`Force ready: logging out and back in agent ${this.defaultAgentId}`,
);
try { try {
await this.ozonetelAgent.logoutAgent({ await this.ozonetelAgent.logoutAgent({
@@ -109,7 +131,8 @@ export class OzonetelAgentController {
}); });
return result; return result;
} catch (error: any) { } catch (error: any) {
const message = error.response?.data?.message ?? error.message ?? 'Force ready failed'; const message =
error.response?.data?.message ?? error.message ?? 'Force ready failed';
this.logger.error(`Force ready failed: ${message}`); this.logger.error(`Force ready failed: ${message}`);
throw new HttpException(message, error.response?.status ?? 502); throw new HttpException(message, error.response?.status ?? 502);
} }
@@ -117,7 +140,8 @@ export class OzonetelAgentController {
@Post('dispose') @Post('dispose')
async dispose( async dispose(
@Body() body: { @Body()
body: {
ucid: string; ucid: string;
disposition: string; disposition: string;
callerPhone?: string; callerPhone?: string;
@@ -132,7 +156,9 @@ export class OzonetelAgentController {
throw new HttpException('ucid and disposition required', 400); throw new HttpException('ucid and disposition required', 400);
} }
this.logger.log(`Dispose: ucid=${body.ucid} disposition=${body.disposition}`); this.logger.log(
`Dispose: ucid=${body.ucid} disposition=${body.disposition}`,
);
const ozonetelDisposition = this.mapToOzonetelDisposition(body.disposition); const ozonetelDisposition = this.mapToOzonetelDisposition(body.disposition);
@@ -143,7 +169,8 @@ export class OzonetelAgentController {
disposition: ozonetelDisposition, disposition: ozonetelDisposition,
}); });
} catch (error: any) { } catch (error: any) {
const message = error.response?.data?.message ?? error.message ?? 'Disposition failed'; const message =
error.response?.data?.message ?? error.message ?? 'Disposition failed';
this.logger.error(`Dispose failed: ${message}`); this.logger.error(`Dispose failed: ${message}`);
} }
@@ -180,15 +207,25 @@ export class OzonetelAgentController {
@Post('dial') @Post('dial')
async dial( async dial(
@Body() body: { phoneNumber: string; campaignName?: string; leadId?: string }, @Body()
body: {
phoneNumber: string;
campaignName?: string;
leadId?: string;
},
) { ) {
if (!body.phoneNumber) { if (!body.phoneNumber) {
throw new HttpException('phoneNumber required', 400); throw new HttpException('phoneNumber required', 400);
} }
const campaignName = body.campaignName ?? process.env.OZONETEL_CAMPAIGN_NAME ?? 'Inbound_918041763265'; const campaignName =
body.campaignName ??
process.env.OZONETEL_CAMPAIGN_NAME ??
'Inbound_918041763265';
this.logger.log(`Manual dial: ${body.phoneNumber} campaign=${campaignName} (lead: ${body.leadId ?? 'none'})`); this.logger.log(
`Manual dial: ${body.phoneNumber} campaign=${campaignName} (lead: ${body.leadId ?? 'none'})`,
);
try { try {
const result = await this.ozonetelAgent.manualDial({ const result = await this.ozonetelAgent.manualDial({
@@ -198,15 +235,23 @@ export class OzonetelAgentController {
}); });
return result; return result;
} catch (error: any) { } catch (error: any) {
const message = error.response?.data?.message ?? error.message ?? 'Dial failed'; const message =
error.response?.data?.message ?? error.message ?? 'Dial failed';
throw new HttpException(message, error.response?.status ?? 502); throw new HttpException(message, error.response?.status ?? 502);
} }
} }
@Post('call-control') @Post('call-control')
async callControl( async callControl(
@Body() body: { @Body()
action: 'CONFERENCE' | 'HOLD' | 'UNHOLD' | 'MUTE' | 'UNMUTE' | 'KICK_CALL'; body: {
action:
| 'CONFERENCE'
| 'HOLD'
| 'UNHOLD'
| 'MUTE'
| 'UNMUTE'
| 'KICK_CALL';
ucid: string; ucid: string;
conferenceNumber?: string; conferenceNumber?: string;
}, },
@@ -215,7 +260,10 @@ export class OzonetelAgentController {
throw new HttpException('action and ucid required', 400); throw new HttpException('action and ucid required', 400);
} }
if (body.action === 'CONFERENCE' && !body.conferenceNumber) { if (body.action === 'CONFERENCE' && !body.conferenceNumber) {
throw new HttpException('conferenceNumber required for CONFERENCE action', 400); throw new HttpException(
'conferenceNumber required for CONFERENCE action',
400,
);
} }
this.logger.log(`Call control: ${body.action} ucid=${body.ucid}`); this.logger.log(`Call control: ${body.action} ucid=${body.ucid}`);
@@ -224,15 +272,14 @@ export class OzonetelAgentController {
const result = await this.ozonetelAgent.callControl(body); const result = await this.ozonetelAgent.callControl(body);
return result; return result;
} catch (error: any) { } catch (error: any) {
const message = error.response?.data?.message ?? error.message ?? 'Call control failed'; const message =
error.response?.data?.message ?? error.message ?? 'Call control failed';
throw new HttpException(message, error.response?.status ?? 502); throw new HttpException(message, error.response?.status ?? 502);
} }
} }
@Post('recording') @Post('recording')
async recording( async recording(@Body() body: { ucid: string; action: 'pause' | 'unPause' }) {
@Body() body: { ucid: string; action: 'pause' | 'unPause' },
) {
if (!body.ucid || !body.action) { if (!body.ucid || !body.action) {
throw new HttpException('ucid and action required', 400); throw new HttpException('ucid and action required', 400);
} }
@@ -241,7 +288,10 @@ export class OzonetelAgentController {
const result = await this.ozonetelAgent.pauseRecording(body); const result = await this.ozonetelAgent.pauseRecording(body);
return result; return result;
} catch (error: any) { } catch (error: any) {
const message = error.response?.data?.message ?? error.message ?? 'Recording control failed'; const message =
error.response?.data?.message ??
error.message ??
'Recording control failed';
throw new HttpException(message, error.response?.status ?? 502); throw new HttpException(message, error.response?.status ?? 502);
} }
} }
@@ -259,7 +309,9 @@ export class OzonetelAgentController {
@Query('callType') callType?: string, @Query('callType') callType?: string,
) { ) {
const targetDate = date ?? new Date().toISOString().split('T')[0]; const targetDate = date ?? new Date().toISOString().split('T')[0];
this.logger.log(`Call history: date=${targetDate} status=${status ?? 'all'} type=${callType ?? 'all'}`); this.logger.log(
`Call history: date=${targetDate} status=${status ?? 'all'} type=${callType ?? 'all'}`,
);
const result = await this.ozonetelAgent.fetchCDR({ const result = await this.ozonetelAgent.fetchCDR({
date: targetDate, date: targetDate,
@@ -272,7 +324,9 @@ export class OzonetelAgentController {
@Get('performance') @Get('performance')
async performance(@Query('date') date?: string) { async performance(@Query('date') date?: string) {
const targetDate = date ?? new Date().toISOString().split('T')[0]; const targetDate = date ?? new Date().toISOString().split('T')[0];
this.logger.log(`Performance: date=${targetDate} agent=${this.defaultAgentId}`); this.logger.log(
`Performance: date=${targetDate} agent=${this.defaultAgentId}`,
);
const [cdr, summary, aht] = await Promise.all([ const [cdr, summary, aht] = await Promise.all([
this.ozonetelAgent.fetchCDR({ date: targetDate }), this.ozonetelAgent.fetchCDR({ date: targetDate }),
@@ -282,9 +336,13 @@ export class OzonetelAgentController {
const totalCalls = cdr.length; const totalCalls = cdr.length;
const inbound = cdr.filter((c: any) => c.Type === 'InBound').length; const inbound = cdr.filter((c: any) => c.Type === 'InBound').length;
const outbound = cdr.filter((c: any) => c.Type === 'Manual' || c.Type === 'Progressive').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 answered = cdr.filter((c: any) => c.Status === 'Answered').length;
const missed = cdr.filter((c: any) => c.Status === 'Unanswered' || c.Status === 'NotAnswered').length; const missed = cdr.filter(
(c: any) => c.Status === 'Unanswered' || c.Status === 'NotAnswered',
).length;
const talkTimes = cdr const talkTimes = cdr
.filter((c: any) => c.TalkTime && c.TalkTime !== '00:00:00') .filter((c: any) => c.TalkTime && c.TalkTime !== '00:00:00')
@@ -292,8 +350,12 @@ export class OzonetelAgentController {
const parts = c.TalkTime.split(':').map(Number); const parts = c.TalkTime.split(':').map(Number);
return parts[0] * 3600 + parts[1] * 60 + parts[2]; return parts[0] * 3600 + parts[1] * 60 + parts[2];
}); });
const avgTalkTimeSec = talkTimes.length > 0 const avgTalkTimeSec =
? Math.round(talkTimes.reduce((a: number, b: number) => a + b, 0) / talkTimes.length) talkTimes.length > 0
? Math.round(
talkTimes.reduce((a: number, b: number) => a + b, 0) /
talkTimes.length,
)
: 0; : 0;
const dispositions: Record<string, number> = {}; const dispositions: Record<string, number> = {};
@@ -311,7 +373,10 @@ export class OzonetelAgentController {
calls: { total: totalCalls, inbound, outbound, answered, missed }, calls: { total: totalCalls, inbound, outbound, answered, missed },
avgTalkTimeSec, avgTalkTimeSec,
avgHandlingTime: aht, avgHandlingTime: aht,
conversionRate: totalCalls > 0 ? Math.round((appointmentsBooked / totalCalls) * 100) : 0, conversionRate:
totalCalls > 0
? Math.round((appointmentsBooked / totalCalls) * 100)
: 0,
appointmentsBooked, appointmentsBooked,
timeUtilization: summary, timeUtilization: summary,
dispositions, dispositions,
@@ -321,12 +386,12 @@ export class OzonetelAgentController {
private mapToOzonetelDisposition(disposition: string): string { private mapToOzonetelDisposition(disposition: string): string {
// Campaign only has 'General Enquiry' configured currently // Campaign only has 'General Enquiry' configured currently
const map: Record<string, string> = { const map: Record<string, string> = {
'APPOINTMENT_BOOKED': 'General Enquiry', APPOINTMENT_BOOKED: 'General Enquiry',
'FOLLOW_UP_SCHEDULED': 'General Enquiry', FOLLOW_UP_SCHEDULED: 'General Enquiry',
'INFO_PROVIDED': 'General Enquiry', INFO_PROVIDED: 'General Enquiry',
'NO_ANSWER': 'General Enquiry', NO_ANSWER: 'General Enquiry',
'WRONG_NUMBER': 'General Enquiry', WRONG_NUMBER: 'General Enquiry',
'CALLBACK_REQUESTED': 'General Enquiry', CALLBACK_REQUESTED: 'General Enquiry',
}; };
return map[disposition] ?? 'General Enquiry'; return map[disposition] ?? 'General Enquiry';
} }

View File

@@ -12,7 +12,8 @@ export class OzonetelAgentService {
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 =
config.get<string>('exotel.subdomain') ?? 'in1-ccaas-api.ozonetel.com';
this.apiKey = config.get<string>('exotel.apiKey') ?? ''; this.apiKey = config.get<string>('exotel.apiKey') ?? '';
this.accountId = config.get<string>('exotel.accountSid') ?? ''; this.accountId = config.get<string>('exotel.accountSid') ?? '';
} }
@@ -25,9 +26,13 @@ export class OzonetelAgentService {
const url = `https://${this.apiDomain}/ca_apis/CAToken/generateToken`; const url = `https://${this.apiDomain}/ca_apis/CAToken/generateToken`;
this.logger.log('Generating CloudAgent API token'); this.logger.log('Generating CloudAgent API token');
const response = await axios.post(url, { userName: this.accountId }, { const response = await axios.post(
url,
{ userName: this.accountId },
{
headers: { apiKey: this.apiKey, 'Content-Type': 'application/json' }, headers: { apiKey: this.apiKey, 'Content-Type': 'application/json' },
}); },
);
const data = response.data; const data = response.data;
if (data.token) { if (data.token) {
@@ -48,7 +53,9 @@ export class OzonetelAgentService {
}): 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 in agent ${params.agentId} with phone ${params.phoneNumber}`); this.logger.log(
`Logging in agent ${params.agentId} with phone ${params.phoneNumber}`,
);
try { try {
const response = await axios.post( const response = await axios.post(
@@ -75,8 +82,13 @@ export class OzonetelAgentService {
const data = response.data; const data = response.data;
// "already logged in" is not a real error — treat as success // "already logged in" is not a real error — treat as success
if (data.status === 'error' && data.message?.includes('already logged in')) { if (
this.logger.log(`Agent ${params.agentId} already logged in — treating as success`); 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 }; return { status: 'success', message: data.message };
} }
@@ -95,27 +107,35 @@ export class OzonetelAgentService {
}): 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(
url,
{
userName: this.accountId, userName: this.accountId,
agentID: params.agentId, agentID: params.agentId,
campaignName: params.campaignName, campaignName: params.campaignName,
customerNumber: params.customerNumber, customerNumber: params.customerNumber,
UCID: 'true', UCID: 'true',
}, { },
{
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', '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
? JSON.stringify(error.response.data)
: '';
this.logger.error(`Manual dial failed: ${error.message} ${responseData}`); this.logger.error(`Manual dial failed: ${error.message} ${responseData}`);
throw error; throw error;
} }
@@ -128,7 +148,9 @@ export class OzonetelAgentService {
}): 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> = {
@@ -148,11 +170,17 @@ export class OzonetelAgentService {
}, },
}); });
this.logger.log(`Change agent state response: ${JSON.stringify(response.data)}`); this.logger.log(
`Change agent state 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(`Change agent state failed: ${error.message} ${responseData}`); ? JSON.stringify(error.response.data)
: '';
this.logger.error(
`Change agent state failed: ${error.message} ${responseData}`,
);
throw error; throw error;
} }
} }
@@ -165,11 +193,15 @@ export class OzonetelAgentService {
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(
url,
{
userName: this.accountId, userName: this.accountId,
agentID: params.agentId, agentID: params.agentId,
did, did,
@@ -177,18 +209,26 @@ export class OzonetelAgentService {
action: 'Set', action: 'Set',
disposition: params.disposition, disposition: params.disposition,
autoRelease: 'true', autoRelease: 'true',
}, { },
{
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}); },
);
this.logger.log(`Set disposition response: ${JSON.stringify(response.data)}`); this.logger.log(
`Set disposition 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(`Set disposition failed: ${error.message} ${responseData}`); ? JSON.stringify(error.response.data)
: '';
this.logger.error(
`Set disposition failed: ${error.message} ${responseData}`,
);
throw error; throw error;
} }
} }
@@ -202,7 +242,9 @@ export class OzonetelAgentService {
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();
@@ -224,11 +266,17 @@ export class OzonetelAgentService {
}, },
}); });
this.logger.log(`Call control response: ${JSON.stringify(response.data)}`); this.logger.log(
`Call control 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(`Call control failed: ${error.message} ${responseData}`); ? JSON.stringify(error.response.data)
: '';
this.logger.error(
`Call control failed: ${error.message} ${responseData}`,
);
throw error; throw error;
} }
} }
@@ -251,11 +299,17 @@ export class OzonetelAgentService {
}, },
}); });
this.logger.log(`Recording control response: ${JSON.stringify(response.data)}`); this.logger.log(
`Recording control 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(`Recording control failed: ${error.message} ${responseData}`); ? JSON.stringify(error.response.data)
: '';
this.logger.error(
`Recording control failed: ${error.message} ${responseData}`,
);
throw error; throw error;
} }
} }
@@ -264,7 +318,8 @@ export class OzonetelAgentService {
fromTime?: string; fromTime?: string;
toTime?: string; toTime?: string;
campaignName?: string; campaignName?: string;
}): Promise<Array<{ }): Promise<
Array<{
monitorUCID: string; monitorUCID: string;
type: string; type: string;
status: string; status: string;
@@ -275,7 +330,8 @@ export class OzonetelAgentService {
agent: string; agent: string;
hangupBy: string; hangupBy: string;
callTime: 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');
@@ -298,7 +354,9 @@ export class OzonetelAgentService {
}); });
const data = response.data; const data = response.data;
this.logger.log(`Abandon calls response: ${JSON.stringify(data).substring(0, 200)}`); this.logger.log(
`Abandon calls response: ${JSON.stringify(data).substring(0, 200)}`,
);
if (data.status === 'success' && Array.isArray(data.message)) { if (data.status === 'success' && Array.isArray(data.message)) {
return data.message; return data.message;
} }
@@ -346,13 +404,18 @@ export class OzonetelAgentService {
} }
return []; return [];
} catch (error: any) { } catch (error: any) {
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : ''; const responseData = error?.response?.data
? JSON.stringify(error.response.data)
: '';
this.logger.error(`Fetch CDR failed: ${error.message} ${responseData}`); this.logger.error(`Fetch CDR failed: ${error.message} ${responseData}`);
return []; return [];
} }
} }
async getAgentSummary(agentId: string, date: string): Promise<{ async getAgentSummary(
agentId: string,
date: string,
): Promise<{
totalLoginDuration: string; totalLoginDuration: string;
totalBusyTime: string; totalBusyTime: string;
totalIdleTime: string; totalIdleTime: string;
@@ -381,7 +444,9 @@ export class OzonetelAgentService {
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)
? data.message[0]
: data.message;
return { return {
totalLoginDuration: record.TotalLoginDuration ?? '00:00:00', totalLoginDuration: record.TotalLoginDuration ?? '00:00:00',
totalBusyTime: record.TotalBusyTime ?? '00:00:00', totalBusyTime: record.TotalBusyTime ?? '00:00:00',
@@ -456,7 +521,9 @@ export class OzonetelAgentService {
}, },
); );
this.logger.log(`Agent logout response: ${JSON.stringify(response.data)}`); this.logger.log(
`Agent logout response: ${JSON.stringify(response.data)}`,
);
return response.data; return response.data;
} catch (error: any) { } catch (error: any) {
this.logger.error(`Agent logout failed: ${error.message}`); this.logger.error(`Agent logout failed: ${error.message}`);

View File

@@ -1,7 +1,13 @@
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 {
@@ -19,14 +25,18 @@ export class PlatformGraphqlService {
} }
// Query using a passed-through auth header (user JWT) // Query using a passed-through auth header (user JWT)
async queryWithAuth<T>(query: string, variables: Record<string, any> | undefined, authHeader: string): Promise<T> { async queryWithAuth<T>(
query: string,
variables: Record<string, any> | undefined,
authHeader: string,
): Promise<T> {
const response = await axios.post( const response = await axios.post(
this.graphqlUrl, this.graphqlUrl,
{ query, variables }, { query, variables },
{ {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': authHeader, Authorization: authHeader,
}, },
}, },
); );
@@ -63,10 +73,16 @@ export class PlatformGraphqlService {
// Client-side phone matching (strip non-digits for comparison) // Client-side phone matching (strip non-digits for comparison)
const normalizedPhone = phone.replace(/\D/g, ''); const normalizedPhone = phone.replace(/\D/g, '');
return data.leads.edges.find(edge => { return (
data.leads.edges.find((edge) => {
const leadPhones = edge.node.contactPhone ?? []; const leadPhones = edge.node.contactPhone ?? [];
return leadPhones.some(p => p.number.replace(/\D/g, '').endsWith(normalizedPhone) || normalizedPhone.endsWith(p.number.replace(/\D/g, ''))); return leadPhones.some(
})?.node ?? null; (p) =>
p.number.replace(/\D/g, '').endsWith(normalizedPhone) ||
normalizedPhone.endsWith(p.number.replace(/\D/g, '')),
);
})?.node ?? null
);
} }
async findLeadById(id: string): Promise<LeadNode | null> { async findLeadById(id: string): Promise<LeadNode | null> {
@@ -110,7 +126,9 @@ export class PlatformGraphqlService {
return data.createCall; return data.createCall;
} }
async createLeadActivity(input: CreateLeadActivityInput): Promise<{ id: string }> { async createLeadActivity(
input: CreateLeadActivityInput,
): Promise<{ id: string }> {
const data = await this.query<{ createLeadActivity: { id: string } }>( const data = await this.query<{ createLeadActivity: { id: string } }>(
`mutation CreateLeadActivity($data: LeadActivityCreateInput!) { `mutation CreateLeadActivity($data: LeadActivityCreateInput!) {
createLeadActivity(data: $data) { id } createLeadActivity(data: $data) { id }
@@ -122,11 +140,16 @@ export class PlatformGraphqlService {
// --- 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(
phone: string,
authHeader: string,
): Promise<LeadNode | null> {
const normalizedPhone = phone.replace(/\D/g, ''); const normalizedPhone = phone.replace(/\D/g, '');
const last10 = normalizedPhone.slice(-10); const last10 = normalizedPhone.slice(-10);
const data = await this.queryWithAuth<{ leads: { edges: { node: LeadNode }[] } }>( const data = await this.queryWithAuth<{
leads: { edges: { node: LeadNode }[] };
}>(
`query FindLeads($first: Int) { `query FindLeads($first: Int) {
leads(first: $first, orderBy: [{ createdAt: DescNullsLast }]) { leads(first: $first, orderBy: [{ createdAt: DescNullsLast }]) {
edges { edges {
@@ -148,22 +171,37 @@ export class PlatformGraphqlService {
); );
// Client-side phone matching // Client-side phone matching
return data.leads.edges.find(edge => { return (
data.leads.edges.find((edge) => {
const phones = edge.node.contactPhone ?? []; const phones = edge.node.contactPhone ?? [];
if (Array.isArray(phones)) { if (Array.isArray(phones)) {
return phones.some((p: any) => { return phones.some((p: any) => {
const num = (p.number ?? p.primaryPhoneNumber ?? '').replace(/\D/g, ''); const num = (p.number ?? p.primaryPhoneNumber ?? '').replace(
/\D/g,
'',
);
return num.endsWith(last10) || last10.endsWith(num); return num.endsWith(last10) || last10.endsWith(num);
}); });
} }
// Handle single phone object // Handle single phone object
const num = ((phones as any).primaryPhoneNumber ?? (phones as any).number ?? '').replace(/\D/g, ''); 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; })?.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,
authHeader: string,
limit = 5,
): Promise<LeadActivityNode[]> {
const data = await this.queryWithAuth<{
leadActivities: { edges: { node: LeadActivityNode }[] };
}>(
`query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) { `query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) {
leadActivities(filter: $filter, first: $first, orderBy: [{ occurredAt: DescNullsLast }]) { leadActivities(filter: $filter, first: $first, orderBy: [{ occurredAt: DescNullsLast }]) {
edges { edges {
@@ -176,10 +214,14 @@ 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(
id: string,
input: UpdateLeadInput,
authHeader: string,
): Promise<LeadNode> {
const data = await this.queryWithAuth<{ updateLead: LeadNode }>( const data = await this.queryWithAuth<{ updateLead: LeadNode }>(
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) { `mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
updateLead(id: $id, data: $data) { updateLead(id: $id, data: $data) {
@@ -194,8 +236,13 @@ export class PlatformGraphqlService {
// --- 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,
limit = 3,
): Promise<LeadActivityNode[]> {
const data = await this.query<{
leadActivities: { edges: { node: LeadActivityNode }[] };
}>(
`query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) { `query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) {
leadActivities(filter: $filter, first: $first, orderBy: { occurredAt: DescNullsLast }) { leadActivities(filter: $filter, first: $first, orderBy: { occurredAt: DescNullsLast }) {
edges { edges {
@@ -207,6 +254,6 @@ 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

@@ -20,7 +20,9 @@ export class SearchController {
return { leads: [], patients: [], appointments: [] }; return { leads: [], patients: [], appointments: [] };
} }
const authHeader = this.platformApiKey ? `Bearer ${this.platformApiKey}` : ''; const authHeader = this.platformApiKey
? `Bearer ${this.platformApiKey}`
: '';
if (!authHeader) { if (!authHeader) {
return { leads: [], patients: [], appointments: [] }; return { leads: [], patients: [], appointments: [] };
} }
@@ -29,31 +31,41 @@ export class SearchController {
// Fetch all three in parallel, filter client-side for flexible matching // Fetch all three in parallel, filter client-side for flexible matching
try { try {
const [leadsResult, patientsResult, appointmentsResult] = await Promise.all([ const [leadsResult, patientsResult, appointmentsResult] =
this.platform.queryWithAuth<any>( await Promise.all([
this.platform
.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
.queryWithAuth<any>(
`{ patients(first: 50) { edges { node { `{ 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
.queryWithAuth<any>(
`{ appointments(first: 50, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node { `{ 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();
@@ -61,18 +73,28 @@ export class SearchController {
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 =
`${l.contactName?.firstName ?? ''} ${l.contactName?.lastName ?? ''}`.toLowerCase();
const phone = l.contactPhone?.primaryPhoneNumber ?? ''; const phone = l.contactPhone?.primaryPhoneNumber ?? '';
return name.includes(q) || phone.includes(q) || (l.name ?? '').toLowerCase().includes(q); return (
name.includes(q) ||
phone.includes(q) ||
(l.name ?? '').toLowerCase().includes(q)
);
}) })
.slice(0, 5); .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 =
`${p.fullName?.firstName ?? ''} ${p.fullName?.lastName ?? ''}`.toLowerCase();
const phone = p.phones?.primaryPhoneNumber ?? ''; const phone = p.phones?.primaryPhoneNumber ?? '';
return name.includes(q) || phone.includes(q) || (p.name ?? '').toLowerCase().includes(q); return (
name.includes(q) ||
phone.includes(q) ||
(p.name ?? '').toLowerCase().includes(q)
);
}) })
.slice(0, 5); .slice(0, 5);

View File

@@ -15,10 +15,15 @@ export class KookooCallbackController {
} }
@Post('callback') @Post('callback')
async handleCallback(@Body() body: Record<string, any>, @Query() query: Record<string, any>) { async handleCallback(
@Body() body: Record<string, any>,
@Query() query: Record<string, any>,
) {
// Kookoo sends params as both query and body // Kookoo sends params as both query and body
const params = { ...query, ...body }; const params = { ...query, ...body };
this.logger.log(`Kookoo callback: sid=${params.sid} status=${params.status} phone=${params.phone_no} duration=${params.duration}`); 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 phoneNumber = (params.phone_no ?? '').replace(/^\+?91/, '');
const status = params.status ?? 'unknown'; const status = params.status ?? 'unknown';
@@ -50,7 +55,9 @@ export class KookooCallbackController {
direction: 'OUTBOUND', direction: 'OUTBOUND',
callStatus, callStatus,
callerNumber: { primaryPhoneNumber: `+91${phoneNumber}` }, callerNumber: { primaryPhoneNumber: `+91${phoneNumber}` },
startedAt: startTime ? new Date(startTime).toISOString() : new Date().toISOString(), startedAt: startTime
? new Date(startTime).toISOString()
: new Date().toISOString(),
endedAt: endTime ? new Date(endTime).toISOString() : null, endedAt: endTime ? new Date(endTime).toISOString() : null,
durationSec: duration, durationSec: duration,
}, },
@@ -58,7 +65,9 @@ export class KookooCallbackController {
authHeader, authHeader,
); );
this.logger.log(`Created outbound call record: ${callResult.createCall.id} (${callStatus}, ${duration}s)`); this.logger.log(
`Created outbound call record: ${callResult.createCall.id} (${callStatus}, ${duration}s)`,
);
// Try to match to a lead // Try to match to a lead
const leadResult = await this.platform.queryWithAuth<any>( const leadResult = await this.platform.queryWithAuth<any>(
@@ -69,7 +78,10 @@ export class KookooCallbackController {
const leads = leadResult.leads.edges.map((e: any) => e.node); const leads = leadResult.leads.edges.map((e: any) => e.node);
const cleanPhone = phoneNumber.replace(/\D/g, ''); const cleanPhone = phoneNumber.replace(/\D/g, '');
const matchedLead = leads.find((l: any) => { const matchedLead = leads.find((l: any) => {
const lp = (l.contactPhone?.primaryPhoneNumber ?? '').replace(/\D/g, ''); const lp = (l.contactPhone?.primaryPhoneNumber ?? '').replace(
/\D/g,
'',
);
return lp.endsWith(cleanPhone) || cleanPhone.endsWith(lp); return lp.endsWith(cleanPhone) || cleanPhone.endsWith(lp);
}); });
@@ -79,13 +91,23 @@ export class KookooCallbackController {
{ id: callResult.createCall.id, data: { leadId: matchedLead.id } }, { id: callResult.createCall.id, data: { leadId: matchedLead.id } },
authHeader, authHeader,
); );
this.logger.log(`Linked call to lead ${matchedLead.id} (${matchedLead.name})`); this.logger.log(
`Linked call to lead ${matchedLead.id} (${matchedLead.name})`,
);
} }
return { received: true, processed: true, callId: callResult.createCall.id }; return {
received: true,
processed: true,
callId: callResult.createCall.id,
};
} catch (err: any) { } catch (err: any) {
const responseData = err?.response?.data ? JSON.stringify(err.response.data) : ''; const responseData = err?.response?.data
this.logger.error(`Kookoo callback processing failed: ${err.message} ${responseData}`); ? JSON.stringify(err.response.data)
: '';
this.logger.error(
`Kookoo callback processing failed: ${err.message} ${responseData}`,
);
return { received: true, processed: false }; return { received: true, processed: false };
} }
} }

View File

@@ -24,7 +24,9 @@ export class MissedCallWebhookController {
payload = body; payload = body;
} }
this.logger.log(`Call webhook: ${payload.CallerID} | ${payload.Status} | ${payload.Type}`); this.logger.log(
`Call webhook: ${payload.CallerID} | ${payload.Status} | ${payload.Type}`,
);
const callerPhone = (payload.CallerID ?? '').replace(/^\+?91/, ''); const callerPhone = (payload.CallerID ?? '').replace(/^\+?91/, '');
const status = payload.Status; // NotAnswered, Answered, Abandoned const status = payload.Status; // NotAnswered, Answered, Abandoned
@@ -50,13 +52,16 @@ export class MissedCallWebhookController {
// Use API key auth for server-to-server writes // Use API key auth for server-to-server writes
const authHeader = this.apiKey ? `Bearer ${this.apiKey}` : ''; const authHeader = this.apiKey ? `Bearer ${this.apiKey}` : '';
if (!authHeader) { if (!authHeader) {
this.logger.warn('No PLATFORM_API_KEY configured — cannot write call records'); this.logger.warn(
'No PLATFORM_API_KEY configured — cannot write call records',
);
return { received: true, processed: false }; return { received: true, processed: false };
} }
try { try {
// Step 1: Create call record // Step 1: Create call record
const callId = await this.createCall({ const callId = await this.createCall(
{
callerPhone, callerPhone,
direction, direction,
callStatus, callStatus,
@@ -67,7 +72,9 @@ export class MissedCallWebhookController {
recordingUrl, recordingUrl,
disposition, disposition,
ucid, ucid,
}, authHeader); },
authHeader,
);
this.logger.log(`Created call record: ${callId} (${callStatus})`); this.logger.log(`Created call record: ${callId} (${callStatus})`);
@@ -79,40 +86,65 @@ export class MissedCallWebhookController {
await this.updateCall(callId, { leadId: lead.id }, authHeader); await this.updateCall(callId, { leadId: lead.id }, authHeader);
// Step 4: Create lead activity // Step 4: Create lead activity
const summary = callStatus === 'MISSED' const summary =
callStatus === 'MISSED'
? `Missed inbound call from ${callerPhone} (${duration}s, ${hangupBy ?? 'unknown'})` ? `Missed inbound call from ${callerPhone} (${duration}s, ${hangupBy ?? 'unknown'})`
: `Inbound call from ${callerPhone}${duration}s, ${disposition || 'no disposition'}`; : `Inbound call from ${callerPhone}${duration}s, ${disposition || 'no disposition'}`;
await this.createLeadActivity({ await this.createLeadActivity(
{
leadId: lead.id, leadId: lead.id,
activityType: callStatus === 'MISSED' ? 'CALL_RECEIVED' : 'CALL_RECEIVED', activityType:
callStatus === 'MISSED' ? 'CALL_RECEIVED' : 'CALL_RECEIVED',
summary, summary,
channel: 'PHONE', channel: 'PHONE',
performedBy: agentName ?? 'System', performedBy: agentName ?? 'System',
durationSeconds: duration, durationSeconds: duration,
outcome: callStatus === 'MISSED' ? 'NO_ANSWER' : 'SUCCESSFUL', outcome: callStatus === 'MISSED' ? 'NO_ANSWER' : 'SUCCESSFUL',
}, authHeader); },
authHeader,
);
// Step 5: Update lead contact timestamps // Step 5: Update lead contact timestamps
await this.updateLead(lead.id, { await this.updateLead(
lastContacted: startTime ? new Date(startTime).toISOString() : new Date().toISOString(), lead.id,
{
lastContacted: startTime
? new Date(startTime).toISOString()
: new Date().toISOString(),
contactAttempts: (lead.contactAttempts ?? 0) + 1, contactAttempts: (lead.contactAttempts ?? 0) + 1,
}, authHeader); },
authHeader,
);
this.logger.log(`Linked call to lead ${lead.id} (${lead.name}), activity created`); this.logger.log(
`Linked call to lead ${lead.id} (${lead.name}), activity created`,
);
} else { } else {
this.logger.log(`No matching lead for ${callerPhone} — call record created without lead link`); this.logger.log(
`No matching lead for ${callerPhone} — call record created without lead link`,
);
} }
return { received: true, processed: true, callId, leadId: lead?.id ?? null }; return {
received: true,
processed: true,
callId,
leadId: lead?.id ?? null,
};
} catch (err: any) { } catch (err: any) {
const responseData = err?.response?.data ? JSON.stringify(err.response.data) : ''; const responseData = err?.response?.data
this.logger.error(`Webhook processing failed: ${err.message} ${responseData}`); ? JSON.stringify(err.response.data)
: '';
this.logger.error(
`Webhook processing failed: ${err.message} ${responseData}`,
);
return { received: true, processed: false, error: String(err) }; return { received: true, processed: false, error: String(err) };
} }
} }
private async createCall(data: { private async createCall(
data: {
callerPhone: string; callerPhone: string;
direction: string; direction: string;
callStatus: string; callStatus: string;
@@ -123,7 +155,9 @@ export class MissedCallWebhookController {
recordingUrl: string | null; recordingUrl: string | null;
disposition: string | null; disposition: string | null;
ucid: string | null; ucid: string | null;
}, authHeader: string): Promise<string> { },
authHeader: string,
): Promise<string> {
const callData: Record<string, any> = { const callData: Record<string, any> = {
name: `${data.direction === 'INBOUND' ? 'Inbound' : 'Outbound'}${data.callerPhone}`, name: `${data.direction === 'INBOUND' ? 'Inbound' : 'Outbound'}${data.callerPhone}`,
direction: data.direction, direction: data.direction,
@@ -136,7 +170,10 @@ export class MissedCallWebhookController {
disposition: this.mapDisposition(data.disposition), disposition: this.mapDisposition(data.disposition),
}; };
if (data.recordingUrl) { if (data.recordingUrl) {
callData.recording = { primaryLinkUrl: data.recordingUrl, primaryLinkLabel: 'Recording' }; callData.recording = {
primaryLinkUrl: data.recordingUrl,
primaryLinkLabel: 'Recording',
};
} }
const result = await this.platform.queryWithAuth<any>( const result = await this.platform.queryWithAuth<any>(
@@ -147,7 +184,10 @@ export class MissedCallWebhookController {
return result.createCall.id; return result.createCall.id;
} }
private async findLeadByPhone(phone: string, authHeader: string): Promise<{ id: string; name: string; contactAttempts: number } | null> { private async findLeadByPhone(
phone: string,
authHeader: string,
): Promise<{ id: string; name: string; contactAttempts: number } | null> {
const result = await this.platform.queryWithAuth<any>( const result = await this.platform.queryWithAuth<any>(
`{ leads(first: 50) { edges { node { id name contactPhone { primaryPhoneNumber } contactAttempts lastContacted } } } }`, `{ leads(first: 50) { edges { node { id name contactPhone { primaryPhoneNumber } contactAttempts lastContacted } } } }`,
undefined, undefined,
@@ -156,13 +196,22 @@ export class MissedCallWebhookController {
const leads = result.leads.edges.map((e: any) => e.node); const leads = result.leads.edges.map((e: any) => e.node);
const cleanPhone = phone.replace(/\D/g, ''); const cleanPhone = phone.replace(/\D/g, '');
return leads.find((l: any) => { return (
const lp = (l.contactPhone?.primaryPhoneNumber ?? '').replace(/\D/g, ''); leads.find((l: any) => {
const lp = (l.contactPhone?.primaryPhoneNumber ?? '').replace(
/\D/g,
'',
);
return lp.endsWith(cleanPhone) || cleanPhone.endsWith(lp); return lp.endsWith(cleanPhone) || cleanPhone.endsWith(lp);
}) ?? null; }) ?? null
);
} }
private async updateCall(callId: string, data: Record<string, any>, authHeader: string): Promise<void> { private async updateCall(
callId: string,
data: Record<string, any>,
authHeader: string,
): Promise<void> {
await this.platform.queryWithAuth<any>( await this.platform.queryWithAuth<any>(
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`, `mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
{ id: callId, data }, { id: callId, data },
@@ -170,7 +219,8 @@ export class MissedCallWebhookController {
); );
} }
private async createLeadActivity(data: { private async createLeadActivity(
data: {
leadId: string; leadId: string;
activityType: string; activityType: string;
summary: string; summary: string;
@@ -178,7 +228,9 @@ export class MissedCallWebhookController {
performedBy: string; performedBy: string;
durationSeconds: number; durationSeconds: number;
outcome: string; outcome: string;
}, authHeader: string): Promise<void> { },
authHeader: string,
): Promise<void> {
await this.platform.queryWithAuth<any>( await this.platform.queryWithAuth<any>(
`mutation($data: LeadActivityCreateInput!) { createLeadActivity(data: $data) { id } }`, `mutation($data: LeadActivityCreateInput!) { createLeadActivity(data: $data) { id } }`,
{ {
@@ -198,7 +250,11 @@ export class MissedCallWebhookController {
); );
} }
private async updateLead(leadId: string, data: Record<string, any>, authHeader: string): Promise<void> { private async updateLead(
leadId: string,
data: Record<string, any>,
authHeader: string,
): Promise<void> {
await this.platform.queryWithAuth<any>( await this.platform.queryWithAuth<any>(
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`, `mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
{ id: leadId, data }, { id: leadId, data },

View File

@@ -8,8 +8,10 @@ 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);
else if (digits.startsWith('0') && digits.length > 10)
digits = digits.slice(1);
return `+91${digits.slice(-10)}`; return `+91${digits.slice(-10)}`;
} }
@@ -25,12 +27,23 @@ export class MissedQueueService implements OnModuleInit {
private readonly platform: PlatformGraphqlService, private readonly platform: PlatformGraphqlService,
private readonly ozonetel: OzonetelAgentService, private readonly ozonetel: OzonetelAgentService,
) { ) {
this.pollIntervalMs = this.config.get<number>('missedQueue.pollIntervalMs', 30000); this.pollIntervalMs = this.config.get<number>(
'missedQueue.pollIntervalMs',
30000,
);
} }
onModuleInit() { onModuleInit() {
this.logger.log(`Starting missed call ingestion polling every ${this.pollIntervalMs}ms`); this.logger.log(
setInterval(() => this.ingest().catch(err => this.logger.error('Ingestion failed', err)), this.pollIntervalMs); `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 }> { async ingest(): Promise<{ created: number; updated: number }> {
@@ -43,7 +56,10 @@ export class MissedQueueService implements OnModuleInit {
let abandonCalls: any[]; let abandonCalls: any[];
try { try {
abandonCalls = await this.ozonetel.getAbandonCalls({ fromTime: format(fiveMinAgo), toTime: format(now) }); abandonCalls = await this.ozonetel.getAbandonCalls({
fromTime: format(fiveMinAgo),
toTime: format(now),
});
} catch (err) { } catch (err) {
this.logger.warn(`Failed to fetch abandon calls: ${err}`); this.logger.warn(`Failed to fetch abandon calls: ${err}`);
return { created: 0, updated: 0 }; return { created: 0, updated: 0 };
@@ -107,10 +123,11 @@ export class MissedQueueService implements OnModuleInit {
if (this.processedUcids.size > 500) { if (this.processedUcids.size > 500) {
const arr = Array.from(this.processedUcids); const arr = Array.from(this.processedUcids);
this.processedUcids.clear(); this.processedUcids.clear();
arr.slice(-200).forEach(u => this.processedUcids.add(u)); arr.slice(-200).forEach((u) => this.processedUcids.add(u));
} }
if (created || updated) this.logger.log(`Ingestion: ${created} created, ${updated} updated`); if (created || updated)
this.logger.log(`Ingestion: ${created} created, ${updated} updated`);
return { created, updated }; return { created, updated };
} }
@@ -165,10 +182,22 @@ export class MissedQueueService implements OnModuleInit {
} }
} }
async updateStatus(callId: string, status: string, authHeader: string): Promise<any> { async updateStatus(
const validStatuses = ['PENDING_CALLBACK', 'CALLBACK_ATTEMPTED', 'CALLBACK_COMPLETED', 'INVALID', 'WRONG_NUMBER']; callId: string,
status: string,
authHeader: string,
): Promise<any> {
const validStatuses = [
'PENDING_CALLBACK',
'CALLBACK_ATTEMPTED',
'CALLBACK_COMPLETED',
'INVALID',
'WRONG_NUMBER',
];
if (!validStatuses.includes(status)) { if (!validStatuses.includes(status)) {
throw new Error(`Invalid status: ${status}. Must be one of: ${validStatuses.join(', ')}`); throw new Error(
`Invalid status: ${status}. Must be one of: ${validStatuses.join(', ')}`,
);
} }
const dataParts: string[] = [`callbackstatus: ${status}`]; const dataParts: string[] = [`callbackstatus: ${status}`];
@@ -183,7 +212,10 @@ export class MissedQueueService implements OnModuleInit {
); );
} }
async getMissedQueue(agentName: string, authHeader: string): Promise<{ async getMissedQueue(
agentName: string,
authHeader: string,
): Promise<{
pending: any[]; pending: any[];
attempted: any[]; attempted: any[];
completed: any[]; completed: any[];
@@ -201,15 +233,37 @@ export class MissedQueueService implements OnModuleInit {
}, orderBy: [{ startedAt: AscNullsLast }]) { edges { node { ${fields} } } } }`; }, orderBy: [{ startedAt: AscNullsLast }]) { edges { node { ${fields} } } } }`;
try { try {
const [pending, attempted, completed, invalid, wrongNumber] = 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),

View File

@@ -1,4 +1,13 @@
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';
@@ -27,7 +36,8 @@ export class WorklistController {
@Get('missed-queue') @Get('missed-queue')
async getMissedQueue(@Headers('authorization') authHeader: string) { async getMissedQueue(@Headers('authorization') authHeader: string) {
if (!authHeader) throw new HttpException('Authorization header required', 401); if (!authHeader)
throw new HttpException('Authorization header required', 401);
const agentName = await this.resolveAgentName(authHeader); const agentName = await this.resolveAgentName(authHeader);
return this.missedQueue.getMissedQueue(agentName, authHeader); return this.missedQueue.getMissedQueue(agentName, authHeader);
} }
@@ -38,7 +48,8 @@ export class WorklistController {
@Headers('authorization') authHeader: string, @Headers('authorization') authHeader: string,
@Body() body: { status: string }, @Body() body: { status: string },
) { ) {
if (!authHeader) throw new HttpException('Authorization header required', 401); if (!authHeader)
throw new HttpException('Authorization header required', 401);
if (!body.status) throw new HttpException('status is required', 400); if (!body.status) throw new HttpException('status is required', 400);
return this.missedQueue.updateStatus(id, body.status, authHeader); return this.missedQueue.updateStatus(id, body.status, authHeader);
} }

View File

@@ -9,7 +9,11 @@ import { KookooCallbackController } from './kookoo-callback.controller';
@Module({ @Module({
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule)], imports: [PlatformModule, forwardRef(() => OzonetelAgentModule)],
controllers: [WorklistController, MissedCallWebhookController, KookooCallbackController], controllers: [
WorklistController,
MissedCallWebhookController,
KookooCallbackController,
],
providers: [WorklistService, MissedQueueService], providers: [WorklistService, MissedQueueService],
exports: [MissedQueueService], exports: [MissedQueueService],
}) })

View File

@@ -14,7 +14,10 @@ export class WorklistService {
constructor(private readonly platform: PlatformGraphqlService) {} constructor(private readonly platform: PlatformGraphqlService) {}
async getWorklist(agentName: string, authHeader: string): Promise<WorklistResponse> { async getWorklist(
agentName: string,
authHeader: string,
): Promise<WorklistResponse> {
const [missedCalls, followUps, marketingLeads] = await Promise.all([ const [missedCalls, followUps, marketingLeads] = await Promise.all([
this.getMissedCalls(agentName, authHeader), this.getMissedCalls(agentName, authHeader),
this.getPendingFollowUps(agentName, authHeader), this.getPendingFollowUps(agentName, authHeader),
@@ -25,11 +28,15 @@ export class WorklistService {
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(
agentName: string,
authHeader: string,
): Promise<any[]> {
try { try {
const data = await this.platform.queryWithAuth<any>( const data = await this.platform.queryWithAuth<any>(
`{ leads(first: 20, filter: { assignedAgent: { eq: "${agentName}" } }, orderBy: [{ createdAt: AscNullsLast }]) { edges { node { `{ leads(first: 20, filter: { assignedAgent: { eq: "${agentName}" } }, orderBy: [{ createdAt: AscNullsLast }]) { edges { node {
@@ -52,7 +59,10 @@ export class WorklistService {
} }
} }
private async getPendingFollowUps(agentName: string, authHeader: string): Promise<any[]> { private async getPendingFollowUps(
agentName: string,
authHeader: string,
): Promise<any[]> {
try { try {
const data = await this.platform.queryWithAuth<any>( const data = await this.platform.queryWithAuth<any>(
`{ followUps(first: 20, filter: { assignedAgent: { eq: "${agentName}" } }) { edges { node { `{ followUps(first: 20, filter: { assignedAgent: { eq: "${agentName}" } }) { edges { node {
@@ -74,7 +84,10 @@ export class WorklistService {
} }
} }
private async getMissedCalls(agentName: string, authHeader: string): Promise<any[]> { private async getMissedCalls(
agentName: string,
authHeader: string,
): Promise<any[]> {
try { try {
// FIFO ordering (AscNullsLast) — oldest first. Filter to active callback statuses only. // FIFO ordering (AscNullsLast) — oldest first. Filter to active callback statuses only.
const data = await this.platform.queryWithAuth<any>( const data = await this.platform.queryWithAuth<any>(