From 229db9b45025887c61b5ab98df205d167efec308 Mon Sep 17 00:00:00 2001 From: Uyoxy Date: Fri, 6 Mar 2026 20:46:01 +0000 Subject: [PATCH 1/4] feat: implement WhatsApp AI agent communication layer with Twilio webhooks --- jest.config.js | 4 +- package-lock.json | 306 +++++++++++++++++++++---- package.json | 11 +- prisma/schema.prisma | 5 + src/index.ts | 53 ++--- src/middleware/auth.ts | 61 +++++ src/routes/deposit.ts | 159 +++++++++++++ src/routes/portfolio.ts | 264 +++++++++++++++++++++ src/routes/protocols.ts | 121 ++++++++++ src/routes/transactions.ts | 175 ++++++++++++++ src/routes/whatsapp.ts | 126 ++++++++++ src/routes/withdraw.ts | 161 +++++++++++++ src/whatsapp/formatters.ts | 300 ++++++++++++++++++++++++ src/whatsapp/handler.ts | 255 +++++++++++++++++++++ src/whatsapp/userManager.ts | 180 +++++++++++++++ tests/integration/api/whatsapp.test.ts | 165 +++++++++++++ 16 files changed, 2268 insertions(+), 78 deletions(-) create mode 100644 src/middleware/auth.ts create mode 100644 src/routes/deposit.ts create mode 100644 src/routes/portfolio.ts create mode 100644 src/routes/protocols.ts create mode 100644 src/routes/transactions.ts create mode 100644 src/routes/whatsapp.ts create mode 100644 src/routes/withdraw.ts create mode 100644 src/whatsapp/formatters.ts create mode 100644 src/whatsapp/handler.ts create mode 100644 src/whatsapp/userManager.ts create mode 100644 tests/integration/api/whatsapp.test.ts diff --git a/jest.config.js b/jest.config.js index 885dd79..c93f086 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,8 +1,8 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', - roots: ['/src'], - testMatch: ['**/__tests__/**/*.test.ts'], + roots: ['/src', '/tests'], + testMatch: ['**/__tests__/**/*.test.ts', '**/*.test.ts'], moduleFileExtensions: ['ts', 'js'], collectCoverageFrom: [ 'src/**/*.ts', diff --git a/package-lock.json b/package-lock.json index bca5eff..73f897d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,25 +12,26 @@ "@anthropic-ai/sdk": "^0.78.0", "@prisma/client": "^5.22.0", "@stellar/stellar-sdk": "^14.5.0", - "bcryptjs": "^3.0.3", "cors": "^2.8.6", "dotenv": "^17.3.1", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "helmet": "^8.1.0", - "jsonwebtoken": "^9.0.3", - "winston": "^3.19.0" + "twilio": "^5.12.2", + "winston": "^3.19.0", + "zod": "^4.3.6" }, "devDependencies": { - "@types/bcryptjs": "^2.4.6", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/jest": "^30.0.0", - "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.3.0", + "@types/supertest": "^7.2.0", + "@types/twilio": "^3.19.2", "jest": "^30.2.0", "nodemon": "^3.1.14", "prisma": "^5.22.0", + "supertest": "^7.2.2", "ts-jest": "^29.4.6", "ts-node": "^10.9.2", "typescript": "^5.9.3" @@ -87,6 +88,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1191,6 +1193,16 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1450,13 +1462,6 @@ "@babel/types": "^7.28.2" } }, - "node_modules/@types/bcryptjs": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", - "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -1478,6 +1483,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -1558,21 +1570,10 @@ "pretty-format": "^30.0.0" } }, - "node_modules/@types/jsonwebtoken": { - "version": "9.0.10", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", - "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/ms": "*", - "@types/node": "*" - } - }, - "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", "dev": true, "license": "MIT" }, @@ -1582,6 +1583,7 @@ "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -1628,12 +1630,46 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.0.tgz", + "integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/triple-beam": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", "license": "MIT" }, + "node_modules/@types/twilio": { + "version": "3.19.2", + "resolved": "https://registry.npmjs.org/@types/twilio/-/twilio-3.19.2.tgz", + "integrity": "sha512-yMEBc7xS1G4Dd4w5xvfDIJkSVVZmiGP/Lrpr4QqUus9rENPjt9BUag5NL198cO2EoJNI8Tqy8qMcKO9jd+9Ssg==", + "dev": true, + "license": "MIT", + "dependencies": { + "twilio": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -1966,6 +2002,18 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -2062,6 +2110,13 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -2251,15 +2306,6 @@ "node": ">=6.0.0" } }, - "node_modules/bcryptjs": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", - "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", - "license": "BSD-3-Clause", - "bin": { - "bcrypt": "bin/bcrypt" - } - }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", @@ -2352,6 +2398,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2784,6 +2831,16 @@ "node": ">=20" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2838,6 +2895,13 @@ "node": ">=6.6.0" } }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -2877,6 +2941,12 @@ "node": ">= 8" } }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2964,6 +3034,17 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", @@ -3241,6 +3322,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -3304,6 +3386,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -3472,6 +3561,24 @@ "node": ">= 0.6" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3501,6 +3608,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -3806,6 +3914,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -4195,6 +4316,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -5084,6 +5206,16 @@ "dev": true, "license": "MIT" }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -5098,6 +5230,19 @@ "node": ">=8.6" } }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -5134,9 +5279,9 @@ } }, "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", + "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -5588,6 +5733,7 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/engines": "5.22.0" }, @@ -5810,6 +5956,13 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/scmp": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/scmp/-/scmp-2.1.0.tgz", + "integrity": "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==", + "deprecated": "Just use Node.js's crypto.timingSafeEqual()", + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -6283,6 +6436,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -6532,6 +6721,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -6578,6 +6768,24 @@ "license": "0BSD", "optional": true }, + "node_modules/twilio": { + "version": "5.12.2", + "resolved": "https://registry.npmjs.org/twilio/-/twilio-5.12.2.tgz", + "integrity": "sha512-yjTH04Ig0Z3PAxIXhwrto0IJC4Gv7lBDQQ9f4/P9zJhnxVdd+3tENqBMJOtdmmRags3X0jl2IGKEQefCEpJE9g==", + "license": "MIT", + "dependencies": { + "axios": "^1.12.0", + "dayjs": "^1.11.9", + "https-proxy-agent": "^5.0.0", + "jsonwebtoken": "^9.0.2", + "qs": "^6.14.1", + "scmp": "^2.1.0", + "xmlbuilder": "^13.0.2" + }, + "engines": { + "node": ">=14.0" + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -6635,6 +6843,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7005,6 +7214,15 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/xmlbuilder": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-13.0.2.tgz", + "integrity": "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==", + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -7118,6 +7336,16 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index dd2466c..89f0edb 100644 --- a/package.json +++ b/package.json @@ -29,25 +29,26 @@ "@anthropic-ai/sdk": "^0.78.0", "@prisma/client": "^5.22.0", "@stellar/stellar-sdk": "^14.5.0", - "bcryptjs": "^3.0.3", "cors": "^2.8.6", "dotenv": "^17.3.1", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "helmet": "^8.1.0", - "jsonwebtoken": "^9.0.3", - "winston": "^3.19.0" + "twilio": "^5.12.2", + "winston": "^3.19.0", + "zod": "^4.3.6" }, "devDependencies": { - "@types/bcryptjs": "^2.4.6", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/jest": "^30.0.0", - "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.3.0", + "@types/supertest": "^7.2.0", + "@types/twilio": "^3.19.2", "jest": "^30.2.0", "nodemon": "^3.1.14", "prisma": "^5.22.0", + "supertest": "^7.2.2", "ts-jest": "^29.4.6", "ts-node": "^10.9.2", "typescript": "^5.9.3" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bf1db2a..f5446df 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -52,12 +52,17 @@ enum AgentStatus { model User { id String @id @default(uuid()) walletAddress String @unique + walletSecret String? // Encrypted Stellar secret key network Network @default(MAINNET) displayName String? email String? @unique + phoneNumber String? @unique // WhatsApp phone number avatarUrl String? riskTolerance Int @default(5) isActive Boolean @default(true) + isVerified Boolean @default(false) // OTP verification status + otpCode String? // Current OTP code + otpExpiresAt DateTime? // OTP expiry timestamp createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/index.ts b/src/index.ts index e63973e..12eb0b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,12 +5,15 @@ import { config } from './config/env' import { errorHandler } from './middleware/errorHandler' import { requestLogger } from './middleware/logger' import { rateLimiter } from './middleware/rateLimiter' -import { AuthMiddleware } from './middleware/authenticate' +import { authenticate } from './middleware/auth' import { logger } from './utils/logger' -import { connectDb } from './db' -import { scheduleSessionCleanup } from './jobs/sessionCleanup' import healthRouter from './routes/health' -import authRouter from './routes/auth' +import portfolioRouter from './routes/portfolio' +import transactionsRouter from './routes/transactions' +import protocolsRouter from './routes/protocols' +import depositRouter from './routes/deposit' +import withdrawRouter from './routes/withdraw' +import whatsappRouter from './routes/whatsapp' const app = express() @@ -23,41 +26,27 @@ app.use(express.json()) app.use(requestLogger) app.use(rateLimiter) -// Public routes +// Routes app.use('/health', healthRouter) -app.use('/api/auth', authRouter) +app.use('/api/whatsapp', whatsappRouter) -// Protected routes (require valid JWT) -// All routes mounted below this line are automatically protected. -app.use('/api/portfolio', AuthMiddleware.validateJwt) -app.use('/api/transactions', AuthMiddleware.validateJwt) -app.use('/api/deposit', AuthMiddleware.validateJwt) -app.use('/api/withdraw', AuthMiddleware.validateJwt) +// Public API Routes +app.use('/api/protocols', protocolsRouter) -// TODO: mount actual portfolio / transaction / deposit / withdraw routers here -// e.g. app.use('/api/portfolio', portfolioRouter) +// Protected API Routes (require authentication) +app.use('/api/portfolio', authenticate, portfolioRouter) +app.use('/api/transactions', authenticate, transactionsRouter) +app.use('/api/deposit', authenticate, depositRouter) +app.use('/api/withdraw', authenticate, withdrawRouter) // Global error handler — must always be last app.use(errorHandler) -async function main() { - // Database connectivity check - await connectDb() - - // Background jobs - scheduleSessionCleanup() - - // Start HTTP server - app.listen(config.port, () => { - logger.info(`NeuroWealth backend running on port ${config.port}`) - logger.info(`Environment: ${config.nodeEnv}`) - logger.info(`Network: ${config.stellar.network}`) - }) -} - -main().catch((error) => { - logger.error('[Startup] Unexpected error:', error) - process.exit(1) +// Start server +app.listen(config.port, () => { + logger.info(`NeuroWealth backend running on port ${config.port}`) + logger.info(`Environment: ${config.nodeEnv}`) + logger.info(`Network: ${config.stellar.network}`) }) export default app \ No newline at end of file diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts new file mode 100644 index 0000000..08b2e2d --- /dev/null +++ b/src/middleware/auth.ts @@ -0,0 +1,61 @@ +import { Request, Response, NextFunction } from 'express' +import { db } from '../db' +import { logger } from '../utils/logger' + +declare global { + namespace Express { + interface Request { + userId?: string + token?: string + } + } +} + +export async function authenticate( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const authHeader = req.headers.authorization + if (!authHeader || !authHeader.startsWith('Bearer ')) { + res.status(401).json({ error: 'Unauthorized', message: 'Missing or invalid token' }) + return + } + + const token = authHeader.slice(7) // Remove 'Bearer ' + + // Verify token exists and hasn't expired + const session = await db.session.findUnique({ + where: { token }, + include: { user: true } + }) + + if (!session) { + res.status(401).json({ error: 'Unauthorized', message: 'Invalid token' }) + return + } + + if (new Date() > session.expiresAt) { + res.status(401).json({ error: 'Unauthorized', message: 'Token expired' }) + return + } + + // Attach user info to request + req.userId = session.userId + req.token = token + + next() + } catch (error) { + logger.error('Authentication error', { error }) + res.status(500).json({ error: 'Internal server error' }) + } +} + +export function requireAuth(req: Request, res: Response, next: NextFunction): void { + if (!req.userId) { + res.status(401).json({ error: 'Unauthorized', message: 'Authentication required' }) + return + } + next() +} diff --git a/src/routes/deposit.ts b/src/routes/deposit.ts new file mode 100644 index 0000000..967c81b --- /dev/null +++ b/src/routes/deposit.ts @@ -0,0 +1,159 @@ +import { Router, Request, Response } from 'express' +import { z } from 'zod' +import { db } from '../db' +import { requireAuth } from '../middleware/auth' +import { whatsappFormatters } from '../whatsapp/formatters' +import { logger } from '../utils/logger' +import { Decimal } from '@prisma/client/runtime/library' + +const router = Router() + +// Validation schema +const depositSchema = z.object({ + userId: z.string().uuid(), + amount: z.string().refine((val) => !isNaN(parseFloat(val)) && parseFloat(val) > 0, { + message: 'Amount must be a positive number' + }), + assetSymbol: z.string().min(1).max(20), + protocolName: z.string().min(1).max(100), + txHash: z.string().min(40).max(140), + memo: z.string().optional() +}) + +/** + * POST /api/deposit + * Initiate a deposit transaction + * Returns 409 if duplicate txHash is detected + */ +router.post('/', requireAuth, async (req: Request, res: Response) => { + try { + const { userId, amount, assetSymbol, protocolName, txHash, memo } = depositSchema.parse(req.body) + + // Verify user can only deposit to their own account + if (req.userId !== userId) { + res.status(403).json({ error: 'Forbidden', message: 'Cannot create deposit for other users' }) + return + } + + const user = await db.user.findUnique({ + where: { id: userId } + }) + + if (!user) { + res.status(404).json({ error: 'Not Found', message: 'User not found' }) + return + } + + // Check for duplicate txHash + const existingTx = await db.transaction.findUnique({ + where: { txHash: txHash } + }) + + if (existingTx) { + res.status(409).json({ + error: 'Conflict', + message: 'Transaction with this hash already exists', + existingTxId: existingTx.id + }) + return + } + + // Try to find or create position + let position = await db.position.findFirst({ + where: { + userId: userId, + protocolName: protocolName, + assetSymbol: assetSymbol, + status: 'ACTIVE' + } + }) + + // Create new position if it doesn't exist + if (!position) { + position = await db.position.create({ + data: { + userId: userId, + protocolName: protocolName, + assetSymbol: assetSymbol, + depositedAmount: new Decimal(amount), + currentValue: new Decimal(amount), + status: 'ACTIVE' + } + }) + } else { + // Update existing position with new deposit + position = await db.position.update({ + where: { id: position.id }, + data: { + depositedAmount: position.depositedAmount.plus(new Decimal(amount)), + currentValue: position.currentValue.plus(new Decimal(amount)) + } + }) + } + + // Create deposit transaction + const transaction = await db.transaction.create({ + data: { + userId: userId, + positionId: position.id, + type: 'DEPOSIT', + status: 'PENDING', + assetSymbol: assetSymbol, + amount: new Decimal(amount), + network: user.network, + protocolName: protocolName, + txHash: txHash, + memo: memo + } + }) + + // Log agent action + await db.agentLog.create({ + data: { + userId: userId, + action: 'DEPOSIT', + status: 'SUCCESS', + inputData: { + amount, + assetSymbol, + protocolName, + txHash + }, + outputData: { + positionId: position.id, + transactionId: transaction.id + } + } + }) + + const depositData = { + transactionId: transaction.id, + positionId: position.id, + userId: userId, + type: 'DEPOSIT', + amount: amount, + assetSymbol: assetSymbol, + protocolName: protocolName, + txHash: txHash, + status: 'PENDING', + createdAt: transaction.createdAt.toISOString(), + whatsappReply: whatsappFormatters.formatDepositConfirmation(amount, assetSymbol, txHash) + } + + res.status(201).json(depositData) + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ + error: 'Validation Error', + message: 'Invalid deposit parameters', + details: error.issues + }) + return + } + + logger.error('Deposit endpoint error', { error, body: req.body }) + res.status(500).json({ error: 'Internal server error' }) + } +}) + +export default router diff --git a/src/routes/portfolio.ts b/src/routes/portfolio.ts new file mode 100644 index 0000000..f22c12d --- /dev/null +++ b/src/routes/portfolio.ts @@ -0,0 +1,264 @@ +import { Router, Request, Response } from 'express' +import { z } from 'zod' +import { db } from '../db' +import { requireAuth } from '../middleware/auth' +import { whatsappFormatters } from '../whatsapp/formatters' +import { logger } from '../utils/logger' +import { Decimal } from '@prisma/client/runtime/library' + +const router = Router() + +// Validation schemas +const userIdSchema = z.string().uuid() +const historyPeriodSchema = z.enum(['7d', '30d', '90d']).optional().default('30d') + +/** + * GET /api/portfolio/:userId + * Get user's portfolio overview with all active positions + */ +router.get('/:userId', requireAuth, async (req: Request, res: Response) => { + try { + const userId = userIdSchema.parse(req.params.userId) + + // Verify user can only access their own portfolio + if (req.userId !== userId) { + res.status(403).json({ error: 'Forbidden', message: 'Cannot access other users portfolio' }) + return + } + + const user = await db.user.findUnique({ + where: { id: userId } + }) + + if (!user) { + res.status(404).json({ error: 'Not Found', message: 'User not found' }) + return + } + + const positions = await db.position.findMany({ + where: { + userId: userId, + status: 'ACTIVE' + }, + include: { + yieldSnapshots: { + orderBy: { snapshotAt: 'desc' }, + take: 1 + } + } + }) + + // Calculate totals + let totalBalance = new Decimal(0) + let totalYield = new Decimal(0) + + positions.forEach((pos) => { + totalBalance = totalBalance.plus(pos.currentValue) + totalYield = totalYield.plus(pos.yieldEarned) + }) + + const portfolioData = { + userId, + totalBalance: totalBalance.toString(), + totalYield: totalYield.toString(), + positionCount: positions.length, + positions: positions.map((pos) => ({ + id: pos.id, + protocolName: pos.protocolName, + assetSymbol: pos.assetSymbol, + depositedAmount: pos.depositedAmount.toString(), + currentValue: pos.currentValue.toString(), + yieldEarned: pos.yieldEarned.toString(), + apy: pos.yieldSnapshots[0]?.apy.toString() || '0', + status: pos.status + })), + whatsappReply: whatsappFormatters.formatPortfolio({ + totalBalance: totalBalance.toString(), + totalYield: totalYield.toString(), + positions: positions.map((pos) => ({ + protocolName: pos.protocolName, + assetSymbol: pos.assetSymbol, + amount: pos.depositedAmount.toString(), + currentValue: pos.currentValue.toString(), + apy: pos.yieldSnapshots[0]?.apy.toString() || '0' + })) + }) + } + + res.status(200).json(portfolioData) + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ error: 'Validation Error', details: error.issues }) + return + } + logger.error('Portfolio endpoint error', { error, userId: req.params.userId }) + res.status(500).json({ error: 'Internal server error' }) + } +}) + +/** + * GET /api/portfolio/:userId/history?period=7d|30d|90d + * Get portfolio history for a specific period + */ +router.get('/:userId/history', requireAuth, async (req: Request, res: Response) => { + try { + const userId = userIdSchema.parse(req.params.userId) + const period = historyPeriodSchema.parse(req.query.period as string) + + if (req.userId !== userId) { + res.status(403).json({ error: 'Forbidden', message: 'Cannot access other users data' }) + return + } + + const user = await db.user.findUnique({ + where: { id: userId } + }) + + if (!user) { + res.status(404).json({ error: 'Not Found', message: 'User not found' }) + return + } + + // Calculate date range + const now = new Date() + const daysBack = period === '7d' ? 7 : period === '30d' ? 30 : 90 + const startDate = new Date(now.getTime() - daysBack * 24 * 60 * 60 * 1000) + + // Get yield snapshots for the period + const snapshots = await db.yieldSnapshot.findMany({ + where: { + position: { userId: userId }, + snapshotAt: { + gte: startDate, + lte: now + } + }, + include: { + position: true + }, + orderBy: { snapshotAt: 'asc' } + }) + + const historyData = { + userId, + period, + startDate, + endDate: now, + snapshotCount: snapshots.length, + snapshots: snapshots.map((snap) => ({ + date: snap.snapshotAt, + protocol: snap.position.protocolName, + asset: snap.position.assetSymbol, + apy: snap.apy.toString(), + yieldAmount: snap.yieldAmount.toString(), + principalAmount: snap.principalAmount.toString() + })), + whatsappReply: + snapshots.length === 0 + ? `šŸ“ˆ *Portfolio History*\n\n_No data for ${period}_` + : `šŸ“ˆ *Portfolio History (${period})*\n\n` + + `Snapshots recorded: ${snapshots.length}\n` + + `Period: ${startDate.toLocaleDateString()} - ${now.toLocaleDateString()}` + } + + res.status(200).json(historyData) + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ error: 'Validation Error', details: error.issues }) + return + } + logger.error('Portfolio history endpoint error', { error, userId: req.params.userId }) + res.status(500).json({ error: 'Internal server error' }) + } +}) + +/** + * GET /api/portfolio/:userId/earnings + * Get earnings summary and breakdown by protocol + */ +router.get('/:userId/earnings', requireAuth, async (req: Request, res: Response) => { + try { + const userId = userIdSchema.parse(req.params.userId) + + if (req.userId !== userId) { + res.status(403).json({ error: 'Forbidden', message: 'Cannot access other users data' }) + return + } + + const user = await db.user.findUnique({ + where: { id: userId } + }) + + if (!user) { + res.status(404).json({ error: 'Not Found', message: 'User not found' }) + return + } + + const positions = await db.position.findMany({ + where: { + userId: userId + }, + include: { + yieldSnapshots: { + orderBy: { snapshotAt: 'desc' }, + take: 1 + } + } + }) + + let totalEarned = new Decimal(0) + let totalPrincipal = new Decimal(0) + const protocolBreakdown: Record = {} + + positions.forEach((pos) => { + const yieldAmount = pos.yieldEarned + totalEarned = totalEarned.plus(yieldAmount) + totalPrincipal = totalPrincipal.plus(pos.depositedAmount) + + if (!protocolBreakdown[pos.protocolName]) { + protocolBreakdown[pos.protocolName] = new Decimal(0) + } + protocolBreakdown[pos.protocolName] = protocolBreakdown[pos.protocolName].plus(yieldAmount) + }) + + const averageApy = + positions.length > 0 + ? positions + .reduce((sum, pos) => sum.plus(pos.yieldSnapshots[0]?.apy || new Decimal(0)), new Decimal(0)) + .div(positions.length) + .toString() + : '0' + + const earningsData = { + userId, + totalEarned: totalEarned.toString(), + totalPrincipal: totalPrincipal.toString(), + averageApy, + apyPercent: parseFloat(averageApy).toFixed(2), + breakdown: Object.entries(protocolBreakdown).map(([protocol, earned]) => ({ + protocol, + earned: earned.toString() + })), + whatsappReply: whatsappFormatters.formatEarnings({ + totalEarned: totalEarned.toString(), + averageApy: parseFloat(averageApy).toFixed(2), + period: 'All time', + breakdown: Object.entries(protocolBreakdown).map(([protocol, earned]) => ({ + protocol, + earned: earned.toString() + })) + }) + } + + res.status(200).json(earningsData) + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ error: 'Validation Error', details: error.issues }) + return + } + logger.error('Portfolio earnings endpoint error', { error, userId: req.params.userId }) + res.status(500).json({ error: 'Internal server error' }) + } +}) + +export default router diff --git a/src/routes/protocols.ts b/src/routes/protocols.ts new file mode 100644 index 0000000..984e776 --- /dev/null +++ b/src/routes/protocols.ts @@ -0,0 +1,121 @@ +import { Router, Request, Response } from 'express' +import { db } from '../db' +import { whatsappFormatters } from '../whatsapp/formatters' +import { logger } from '../utils/logger' + +const router = Router() + +/** + * GET /api/protocols/rates + * Get current rates and APYs for available protocols (no auth required for discovery) + */ +router.get('/rates', async (req: Request, res: Response) => { + try { + // Get the latest protocol rates across all networks + const now = new Date() + const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000) + + const rates = await db.protocolRate.findMany({ + where: { + fetchedAt: { + gte: oneDayAgo, + lte: now + } + }, + orderBy: [{ fetchedAt: 'desc' }, { protocolName: 'asc' }], + distinct: ['protocolName', 'assetSymbol'] + }) + + // Group by protocol and asset + const protocolMap: Record> = {} + + rates.forEach((rate) => { + const key = rate.protocolName + if (!protocolMap[key]) { + protocolMap[key] = [] + } + + protocolMap[key].push({ + name: rate.protocolName, + asset: rate.assetSymbol, + apy: rate.supplyApy.toString(), + tvl: rate.tvl?.toString() + }) + }) + + const protocolsData = { + fetchedAt: now, + protocols: Object.values(protocolMap).flat(), + count: Object.values(protocolMap).flat().length, + whatsappReply: whatsappFormatters.formatProtocolRates({ + protocols: Object.values(protocolMap).flat() + }) + } + + res.status(200).json(protocolsData) + } catch (error) { + logger.error('Protocol rates endpoint error', { error }) + res.status(500).json({ error: 'Internal server error' }) + } +}) + +/** + * GET /api/protocols/agent/status + * Get the current status and performance of the agent + */ +router.get('/agent/status', async (req: Request, res: Response) => { + try { + // Get the most recent agent action + const lastLog = await db.agentLog.findFirst({ + orderBy: { createdAt: 'desc' }, + take: 1 + }) + + if (!lastLog) { + const statusData = { + status: 'IDLE', + lastAction: 'None', + lastActionTime: new Date().toISOString(), + successRate: '0', + whatsappReply: whatsappFormatters.formatAgentStatus({ + status: 'IDLE', + lastAction: 'No activity', + lastActionTime: 'N/A', + successRate: '0' + }) + } + res.status(200).json(statusData) + return + } + + // Calculate success rate from recent logs (last 100 actions) + const recentLogs = await db.agentLog.findMany({ + orderBy: { createdAt: 'desc' }, + take: 100 + }) + + const successCount = recentLogs.filter((log) => log.status === 'SUCCESS').length + const successRate = ((successCount / recentLogs.length) * 100).toFixed(1) + + const statusData = { + status: lastLog.status, + lastAction: lastLog.action, + lastActionTime: lastLog.createdAt.toISOString(), + successRate: successRate, + responseTimeMs: lastLog.durationMs || 0, + whatsappReply: whatsappFormatters.formatAgentStatus({ + status: lastLog.status, + lastAction: lastLog.action, + lastActionTime: lastLog.createdAt.toLocaleString(), + successRate: successRate + }) + } + + res.status(200).json(statusData) + } catch (error) { + logger.error('Agent status endpoint error', { error }) + res.status(500).json({ error: 'Internal server error' }) + } +}) + +export default router diff --git a/src/routes/transactions.ts b/src/routes/transactions.ts new file mode 100644 index 0000000..44758e2 --- /dev/null +++ b/src/routes/transactions.ts @@ -0,0 +1,175 @@ +import { Router, Request, Response } from 'express' +import { z } from 'zod' +import { db } from '../db' +import { requireAuth } from '../middleware/auth' +import { whatsappFormatters } from '../whatsapp/formatters' +import { logger } from '../utils/logger' + +const router = Router() + +// Validation schemas +const userIdSchema = z.string().uuid() +const paginationSchema = z.object({ + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(50).default(5) +}) + +const txHashSchema = z.string().min(40) + +/** + * GET /api/transactions/:userId?page=1&limit=5 + * Get paginated user transaction history + * Default limit = 5 (WhatsApp readability) + */ +router.get('/:userId', requireAuth, async (req: Request, res: Response) => { + try { + const userId = userIdSchema.parse(req.params.userId) + const { page, limit } = paginationSchema.parse({ + page: req.query.page, + limit: req.query.limit + }) + + if (req.userId !== userId) { + res.status(403).json({ error: 'Forbidden', message: 'Cannot access other users transactions' }) + return + } + + const user = await db.user.findUnique({ + where: { id: userId } + }) + + if (!user) { + res.status(404).json({ error: 'Not Found', message: 'User not found' }) + return + } + + // Calculate pagination + const skip = (page - 1) * limit + + const [transactions, total] = await Promise.all([ + db.transaction.findMany({ + where: { userId: userId }, + orderBy: { createdAt: 'desc' }, + skip, + take: limit, + include: { + position: { + select: { protocolName: true } + } + } + }), + db.transaction.count({ + where: { userId: userId } + }) + ]) + + const totalPages = Math.ceil(total / limit) + + const transactionData = { + userId, + page, + limit, + total, + totalPages, + hasMore: page < totalPages, + transactions: transactions.map((tx) => ({ + id: tx.id, + type: tx.type, + assetSymbol: tx.assetSymbol, + amount: tx.amount.toString(), + fee: tx.fee?.toString(), + status: tx.status, + protocol: tx.protocolName || tx.position?.protocolName, + date: tx.createdAt.toISOString(), + txHash: tx.txHash, + memo: tx.memo + })), + whatsappReply: whatsappFormatters.formatTransactionHistory({ + transactions: transactions.map((tx) => ({ + type: tx.type, + assetSymbol: tx.assetSymbol, + amount: tx.amount.toString(), + status: tx.status, + date: tx.createdAt.toLocaleDateString() + })), + hasMore: page < totalPages, + page + }) + } + + res.status(200).json(transactionData) + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ error: 'Validation Error', details: error.issues }) + return + } + logger.error('Transactions endpoint error', { error, userId: req.params.userId }) + res.status(500).json({ error: 'Internal server error' }) + } +}) + +/** + * GET /api/transactions/detail/:txHash + * Get detailed information about a single transaction + */ +router.get('/detail/:txHash', requireAuth, async (req: Request, res: Response) => { + try { + const txHash = txHashSchema.parse(req.params.txHash) + + const transaction = await db.transaction.findUnique({ + where: { txHash: txHash }, + include: { + position: { + select: { protocolName: true } + } + } + }) + + if (!transaction) { + res.status(404).json({ error: 'Not Found', message: 'Transaction not found' }) + return + } + + // Verify user can access this transaction + if (transaction.userId !== req.userId) { + res.status(403).json({ error: 'Forbidden', message: 'Cannot access other users transactions' }) + return + } + + const transactionDetailData = { + id: transaction.id, + type: transaction.type, + assetSymbol: transaction.assetSymbol, + amount: transaction.amount.toString(), + fee: transaction.fee?.toString(), + status: transaction.status, + protocol: transaction.protocolName || transaction.position?.protocolName, + date: transaction.createdAt.toISOString(), + txHash: transaction.txHash!, + memo: transaction.memo, + confirmedAt: transaction.confirmedAt?.toISOString(), + whatsappReply: whatsappFormatters.formatTransactionDetail({ + type: transaction.type, + assetSymbol: transaction.assetSymbol, + amount: transaction.amount.toString(), + fee: transaction.fee?.toString(), + status: transaction.status, + date: transaction.createdAt.toLocaleDateString(), + txHash: transaction.txHash!, + protocol: transaction.protocolName || transaction.position?.protocolName, + memo: transaction.memo || undefined + }) + } + + res.status(200).json(transactionDetailData) + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ error: 'Validation Error', details: error.issues }) + return + } + logger.error('Transaction detail endpoint error', { error, txHash: req.params.txHash }) + res.status(500).json({ error: 'Internal server error' }) + } +}) + +export default router diff --git a/src/routes/whatsapp.ts b/src/routes/whatsapp.ts new file mode 100644 index 0000000..4a68473 --- /dev/null +++ b/src/routes/whatsapp.ts @@ -0,0 +1,126 @@ +import { Router, Request, Response } from 'express' +import { Twilio } from 'twilio' +import { validateRequest } from 'twilio' +import { WhatsAppHandler } from '../whatsapp/handler' +import { logger } from '../utils/logger' +import { config } from '../config/env' + +const router = Router() + +// Initialize Twilio client +const twilio = new Twilio( + process.env.TWILIO_ACCOUNT_SID!, + process.env.TWILIO_AUTH_TOKEN! +) + +/** + * Health check endpoint for webhook verification + */ +router.get('/webhook', (req: Request, res: Response) => { + logger.info('WhatsApp webhook health check') + res.status(200).send('WhatsApp webhook is active') +}) + +/** + * Handle incoming WhatsApp messages from Twilio + */ +router.post('/webhook', async (req: Request, res: Response) => { + try { + // Validate Twilio signature for security + const isValidSignature = validateTwilioSignature(req) + if (!isValidSignature) { + logger.warn('Invalid Twilio signature', { body: req.body }) + return res.status(403).send('Invalid signature') + } + + const { From: from, Body: body } = req.body + + if (!from || !body) { + logger.warn('Missing required fields in webhook', { from, body }) + return res.status(400).send('Missing required fields') + } + + // Clean phone number (Twilio format: whatsapp:+1234567890) + const phoneNumber = from.replace('whatsapp:', '') + + logger.info('Received WhatsApp message', { + from: phoneNumber, + message: body.substring(0, 100) + (body.length > 100 ? '...' : '') + }) + + // Handle the message + const result = await WhatsAppHandler.handleMessage(phoneNumber, body) + + // Send response back via TwiML + const twiml = generateTwiMLResponse(result.message) + + res.type('text/xml') + res.send(twiml) + + } catch (error) { + logger.error('Error processing WhatsApp webhook', { error }) + const errorTwiML = generateTwiMLResponse( + 'Sorry, I encountered an error. Please try again later.' + ) + res.type('text/xml') + res.send(errorTwiML) + } +}) + +/** + * Validate Twilio webhook signature + */ +function validateTwilioSignature(req: Request): boolean { + try { + const twilioSignature = req.headers['x-twilio-signature'] as string + const authToken = process.env.TWILIO_AUTH_TOKEN! + + if (!twilioSignature || !authToken) { + return false + } + + // Get the full URL + const url = `${req.protocol}://${req.get('host')}${req.originalUrl}` + + // Validate signature + const isValid = validateRequest( + authToken, + twilioSignature, + url, + req.body + ) + + return isValid + } catch (error) { + logger.error('Error validating Twilio signature', { error }) + return false + } +} + +/** + * Generate TwiML response for WhatsApp + */ +function generateTwiMLResponse(message: string): string { + return ` + + ${escapeXml(message)} +` +} + +/** + * Escape XML characters + */ +function escapeXml(unsafe: string): string { + return unsafe.replace(/[<>&'"]/g, (c) => { + switch (c) { + case '<': return '<' + case '>': return '>' + case '&': return '&' + case "'": return ''' + case '"': return '"' + default: return c + } + }) +} + +export default router \ No newline at end of file diff --git a/src/routes/withdraw.ts b/src/routes/withdraw.ts new file mode 100644 index 0000000..269c67b --- /dev/null +++ b/src/routes/withdraw.ts @@ -0,0 +1,161 @@ +import { Router, Request, Response } from 'express' +import { z } from 'zod' +import { db } from '../db' +import { requireAuth } from '../middleware/auth' +import { whatsappFormatters } from '../whatsapp/formatters' +import { logger } from '../utils/logger' +import { Decimal } from '@prisma/client/runtime/library' + +const router = Router() + +// Validation schema +const withdrawSchema = z.object({ + userId: z.string().uuid(), + positionId: z.string().uuid(), + amount: z.string().refine((val) => !isNaN(parseFloat(val)) && parseFloat(val) > 0, { + message: 'Amount must be a positive number' + }), + assetSymbol: z.string().min(1).max(20), + txHash: z.string().min(40).max(140), + memo: z.string().optional() +}) + +/** + * POST /api/withdraw + * Initiate a withdrawal transaction + * Returns 409 if duplicate txHash is detected + */ +router.post('/', requireAuth, async (req: Request, res: Response) => { + try { + const { userId, positionId, amount, assetSymbol, txHash, memo } = withdrawSchema.parse(req.body) + + // Verify user can only withdraw from their own account + if (req.userId !== userId) { + res.status(403).json({ error: 'Forbidden', message: 'Cannot create withdrawal for other users' }) + return + } + + const user = await db.user.findUnique({ + where: { id: userId } + }) + + if (!user) { + res.status(404).json({ error: 'Not Found', message: 'User not found' }) + return + } + + // Verify position exists and belongs to user + const position = await db.position.findUnique({ + where: { id: positionId } + }) + + if (!position) { + res.status(404).json({ error: 'Not Found', message: 'Position not found' }) + return + } + + if (position.userId !== userId) { + res.status(403).json({ error: 'Forbidden', message: 'Cannot withdraw from other users positions' }) + return + } + + // Check if withdrawal amount is valid + const withdrawAmount = new Decimal(amount) + if (withdrawAmount.greaterThan(position.currentValue)) { + res.status(400).json({ + error: 'Bad Request', + message: 'Withdrawal amount exceeds position value', + availableBalance: position.currentValue.toString() + }) + return + } + + // Check for duplicate txHash + const existingTx = await db.transaction.findUnique({ + where: { txHash: txHash } + }) + + if (existingTx) { + res.status(409).json({ + error: 'Conflict', + message: 'Transaction with this hash already exists', + existingTxId: existingTx.id + }) + return + } + + // Update position balance + const updatedPosition = await db.position.update({ + where: { id: positionId }, + data: { + currentValue: position.currentValue.minus(withdrawAmount) + } + }) + + // Create withdrawal transaction + const transaction = await db.transaction.create({ + data: { + userId: userId, + positionId: positionId, + type: 'WITHDRAWAL', + status: 'PENDING', + assetSymbol: assetSymbol, + amount: withdrawAmount, + network: user.network, + protocolName: position.protocolName, + txHash: txHash, + memo: memo + } + }) + + // Log agent action + await db.agentLog.create({ + data: { + userId: userId, + action: 'WITHDRAW', + status: 'SUCCESS', + inputData: { + positionId, + amount, + assetSymbol, + txHash + }, + outputData: { + transactionId: transaction.id, + newPositionValue: updatedPosition.currentValue.toString() + } + } + }) + + const withdrawData = { + transactionId: transaction.id, + positionId: positionId, + userId: userId, + type: 'WITHDRAWAL', + amount: amount, + assetSymbol: assetSymbol, + protocolName: position.protocolName, + txHash: txHash, + status: 'PENDING', + remainingBalance: updatedPosition.currentValue.toString(), + createdAt: transaction.createdAt.toISOString(), + whatsappReply: whatsappFormatters.formatWithdrawalConfirmation(amount, assetSymbol, txHash) + } + + res.status(201).json(withdrawData) + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ + error: 'Validation Error', + message: 'Invalid withdrawal parameters', + details: error.issues + }) + return + } + + logger.error('Withdraw endpoint error', { error, body: req.body }) + res.status(500).json({ error: 'Internal server error' }) + } +}) + +export default router diff --git a/src/whatsapp/formatters.ts b/src/whatsapp/formatters.ts new file mode 100644 index 0000000..bd2349f --- /dev/null +++ b/src/whatsapp/formatters.ts @@ -0,0 +1,300 @@ +/** + * WhatsApp Formatter Layer + * Converts structured API responses into clean, readable WhatsApp messages + * Uses WhatsApp text formatting: *bold*, _italic_, line breaks + */ + +interface PortfolioData { + totalBalance: string + totalYield: string + positions: Array<{ + protocolName: string + assetSymbol: string + amount: string + currentValue: string + apy: string + }> +} + +interface TransactionData { + transactions: Array<{ + type: string + assetSymbol: string + amount: string + status: string + date: string + txHash?: string + }> + hasMore: boolean + page: number +} + +interface TransactionDetailData { + type: string + assetSymbol: string + amount: string + fee?: string + status: string + date: string + txHash: string + protocol?: string + memo?: string +} + +interface EarningsData { + totalEarned: string + averageApy: string + period: string + breakdown: Array<{ + protocol: string + earned: string + }> +} + +interface ProtocolRatesData { + protocols: Array<{ + name: string + asset: string + apy: string + tvl?: string + }> +} + +interface AgentStatusData { + status: string + lastAction: string + lastActionTime: string + successRate: string +} + +export const whatsappFormatters = { + /** + * Format portfolio balance for WhatsApp + */ + formatPortfolio: (data: PortfolioData): string => { + if (!data.positions || data.positions.length === 0) { + return `šŸ’¼ *Your Portfolio*\n\n_No active positions_` + } + + let message = `šŸ’¼ *Your Portfolio*\n\n` + message += `šŸ’° *Total Balance:* $${data.totalBalance}\n` + message += `šŸ“ˆ *Total Yield Earned:* $${data.totalYield}\n\n` + message += `*Positions:*\n` + + data.positions.forEach((pos, idx) => { + message += `\n${idx + 1}. *${pos.assetSymbol}* on ${pos.protocolName}\n` + message += ` Deposited: $${pos.amount}\n` + message += ` Current Value: $${pos.currentValue}\n` + message += ` APY: ${pos.apy}%` + }) + + return message + }, + + /** + * Format transaction history for WhatsApp + */ + formatTransactionHistory: (data: TransactionData): string => { + if (!data.transactions || data.transactions.length === 0) { + return `šŸ“‹ *Transaction History*\n\n_No transactions found_` + } + + let message = `šŸ“‹ *Recent Transactions*\n` + message += `_(Page ${data.page})_\n\n` + + data.transactions.forEach((tx) => { + const emoji = + tx.type === 'DEPOSIT' + ? 'ā¬‡ļø' + : tx.type === 'WITHDRAWAL' + ? 'ā¬†ļø' + : tx.type === 'YIELD_CLAIM' + ? 'šŸŽ' + : 'šŸ”„' + + const statusEmoji = + tx.status === 'CONFIRMED' ? 'āœ…' : tx.status === 'PENDING' ? 'ā³' : 'āŒ' + + message += `${emoji} ${tx.type}\n` + message += ` ${tx.amount} ${tx.assetSymbol}\n` + message += ` ${statusEmoji} ${tx.status}\n` + message += ` ${tx.date}\n\n` + }) + + if (data.hasMore) { + message += `_More transactions available. Reply "next" for more._` + } + + return message + }, + + /** + * Format single transaction details for WhatsApp + */ + formatTransactionDetail: (data: TransactionDetailData): string => { + const statusEmoji = + data.status === 'CONFIRMED' + ? 'āœ…' + : data.status === 'PENDING' + ? 'ā³' + : 'āŒ' + + let message = `šŸ“Š *Transaction Details*\n\n` + message += `*Type:* ${data.type}\n` + message += `*Asset:* ${data.assetSymbol}\n` + message += `*Amount:* ${data.amount}\n` + + if (data.fee) { + message += `*Fee:* ${data.fee}\n` + } + + message += `*Status:* ${statusEmoji} ${data.status}\n` + message += `*Date:* ${data.date}\n` + + if (data.protocol) { + message += `*Protocol:* ${data.protocol}\n` + } + + if (data.memo) { + message += `*Note:* ${data.memo}\n` + } + + message += `\n_Hash: ${data.txHash.slice(0, 12)}..._` + + return message + }, + + /** + * Format earnings summary for WhatsApp + */ + formatEarnings: (data: EarningsData): string => { + let message = `šŸ’µ *Earnings Report*\n` + message += `${data.period}\n\n` + message += `*Total Earned:* $${data.totalEarned}\n` + message += `*Average APY:* ${data.averageApy}%\n\n` + + if (data.breakdown && data.breakdown.length > 0) { + message += `*By Protocol:*\n` + data.breakdown.forEach((item) => { + message += `• ${item.protocol}: $${item.earned}\n` + }) + } + + return message + }, + + /** + * Format available protocols and rates for WhatsApp + */ + formatProtocolRates: (data: ProtocolRatesData): string => { + if (!data.protocols || data.protocols.length === 0) { + return `šŸ¦ *Available Protocols*\n\n_No protocols available_` + } + + let message = `šŸ¦ *Available Protocols & Rates*\n\n` + + data.protocols.forEach((proto) => { + message += `*${proto.name}*\n` + message += ` ${proto.asset}: ${proto.apy}% APY\n` + + if (proto.tvl) { + message += ` TVL: $${proto.tvl}\n` + } + + message += `\n` + }) + + return message + }, + + /** + * Format agent status for WhatsApp + */ + formatAgentStatus: (data: AgentStatusData): string => { + const statusEmoji = data.status === 'SUCCESS' ? 'āœ…' : data.status === 'FAILED' ? 'āŒ' : 'ā³' + + let message = `šŸ¤– *Agent Status*\n\n` + message += `${statusEmoji} *Status:* ${data.status}\n` + message += `*Last Action:* ${data.lastAction}\n` + message += `*Time:* ${data.lastActionTime}\n` + message += `*Success Rate:* ${data.successRate}%\n` + + return message + }, + + /** + * Format deposit confirmation for WhatsApp + */ + formatDepositConfirmation: (amount: string, asset: string, txHash: string): string => { + return ( + `āœ… *Deposit Initiated*\n\n` + + `šŸ’° Amount: ${amount} ${asset}\n` + + `šŸ”— Hash: ${txHash.slice(0, 12)}...\n\n` + + `_Waiting for network confirmation..._` + ) + }, + + /** + * Format withdrawal confirmation for WhatsApp + */ + formatWithdrawalConfirmation: (amount: string, asset: string, txHash: string): string => { + return ( + `āœ… *Withdrawal Initiated*\n\n` + + `šŸ’° Amount: ${amount} ${asset}\n` + + `šŸ”— Hash: ${txHash.slice(0, 12)}...\n\n` + + `_Waiting for network confirmation..._` + ) + }, + + /** + * Format error message for WhatsApp + */ + formatError: (errorType: string, message: string): string => { + const emoji = 'āŒ' + return `${emoji} *Error*\n\n${errorType}\n_${message}_` + }, + + /** + * Format validation error for WhatsApp + */ + formatValidationError: (fields: string[]): string => { + let message = `āš ļø *Invalid Input*\n\n` + message += `Please check:\n` + fields.forEach((field) => { + message += `• ${field}\n` + }) + return message + }, +} + +// Individual exports for easier importing +export const formatBalance = whatsappFormatters.formatPortfolio +export const formatTransactions = whatsappFormatters.formatTransactionHistory +export const formatTransactionDetail = whatsappFormatters.formatTransactionDetail +export const formatEarnings = whatsappFormatters.formatEarnings +export const formatProtocolRates = whatsappFormatters.formatProtocolRates +export const formatAgentStatus = whatsappFormatters.formatAgentStatus +export const formatDeposit = (data: { amount: string; currency: string; walletAddress: string; status: string }) => { + return `šŸ’° *Deposit Instructions*\n\n` + + `Send *${data.amount} ${data.currency}* to:\n` + + `\`${data.walletAddress}\`\n\n` + + `Status: ${data.status === 'pending' ? 'ā³ Pending' : 'āœ… Confirmed'}\n\n` + + `_Once sent, your deposit will be processed automatically._` +} +export const formatWithdraw = (data: { amount: string; currency: string; status: string }) => { + return `šŸ’ø *Withdrawal Request*\n\n` + + `Amount: *${data.amount} ${data.currency}*\n` + + `Status: ${data.status === 'processing' ? 'ā³ Processing' : 'āœ… Completed'}\n\n` + + `_Your withdrawal is being processed. You'll receive a confirmation once complete._` +} +export const formatHelp = () => { + return `šŸ¤– *NeuroWealth AI Agent*\n\n` + + `*Available Commands:*\n\n` + + `šŸ’° *balance* - Check your portfolio\n` + + `ā¬‡ļø *deposit [amount] [currency]* - Deposit funds\n` + + `ā¬†ļø *withdraw [amount] [currency]* - Withdraw funds\n` + + `šŸ’µ *earnings* - View yield earnings\n` + + `šŸ“‹ *transactions* - Recent transactions\n` + + `ā“ *help* - Show this message\n\n` + + `_Example: "deposit 100 USDC"_` +} diff --git a/src/whatsapp/handler.ts b/src/whatsapp/handler.ts new file mode 100644 index 0000000..8e3bd52 --- /dev/null +++ b/src/whatsapp/handler.ts @@ -0,0 +1,255 @@ +import { Request, Response } from 'express' +import { UserManager } from './userManager' +import { parseWithRegex } from '../nlp/parser' +import { formatBalance, formatDeposit, formatWithdraw, formatEarnings, formatHelp } from './formatters' +import { logger } from '../utils/logger' +import { db } from '../db' + +export interface IntentResult { + success: boolean + message: string + needsVerification?: boolean +} + +export class WhatsAppHandler { + /** + * Handle incoming WhatsApp message and return formatted response + */ + static async handleMessage( + phoneNumber: string, + message: string + ): Promise { + try { + // Find or create user + const user = await UserManager.findOrCreateUser(phoneNumber) + + // Check if user needs verification + if (!user.isVerified) { + return this.handleUnverifiedUser(user.id, message) + } + + // Parse intent + const intent = parseWithRegex(message) + if (!intent) { + return { + success: true, + message: formatHelp() + } + } + + // Handle different intents + switch (intent.action) { + case 'balance': + return await this.handleBalance(user.id) + case 'deposit': + return await this.handleDeposit(user, intent) + case 'withdraw': + return await this.handleWithdraw(user, intent) + case 'help': + return { + success: true, + message: formatHelp() + } + default: + return { + success: true, + message: formatHelp() + } + } + } catch (error) { + logger.error('Error handling WhatsApp message', { error, phoneNumber }) + return { + success: false, + message: 'Sorry, I encountered an error. Please try again later.' + } + } + } + + /** + * Handle unverified user (OTP flow) + */ + private static async handleUnverifiedUser( + userId: string, + message: string + ): Promise { + const cleanMessage = message.trim().toLowerCase() + + // Check if this is an OTP verification attempt + if (/^\d{6}$/.test(cleanMessage)) { + const isValid = await UserManager.verifyOTP(userId, cleanMessage) + if (isValid) { + return { + success: true, + message: `āœ… *Account Verified!*\n\nWelcome to NeuroWealth! šŸŽ‰\n\nYou can now:\n• Check your *balance*\n• Make *deposits*\n• Request *withdrawals*\n• View *earnings*\n\nType *help* for more commands.` + } + } else { + return { + success: false, + message: 'āŒ *Invalid OTP*\n\nPlease check your code and try again.' + } + } + } + + // Send new OTP + const otp = await UserManager.generateOTP(userId) + // In production, send OTP via Twilio SMS + logger.info('OTP generated for WhatsApp user', { userId, otp }) + + return { + success: true, + message: `šŸ‘‹ *Welcome to NeuroWealth!*\n\nTo get started, please verify your account with the OTP code sent to your phone.\n\n*Demo OTP: ${otp}*\n\nReply with the 6-digit code to continue.`, + needsVerification: true + } + } + + /** + * Handle balance inquiry + */ + private static async handleBalance(userId: string): Promise { + try { + // Get user's positions and calculate totals + const positions = await db.position.findMany({ + where: { + userId, + status: 'ACTIVE' + }, + include: { + yieldSnapshots: { + orderBy: { snapshotAt: 'desc' }, + take: 1 + } + } + }) + + const totalBalance = positions.reduce((sum, pos) => + sum + Number(pos.currentValue), 0 + ) + + const totalYield = positions.reduce((sum, pos) => + sum + Number(pos.yieldEarned), 0 + ) + + const formattedPositions = positions.map(pos => ({ + protocolName: pos.protocolName, + assetSymbol: pos.assetSymbol, + amount: pos.depositedAmount.toString(), + currentValue: pos.currentValue.toString(), + apy: pos.yieldSnapshots[0]?.apy.toString() || '0' + })) + + return { + success: true, + message: formatBalance({ + totalBalance: totalBalance.toFixed(2), + totalYield: totalYield.toFixed(2), + positions: formattedPositions + }) + } + } catch (error) { + logger.error('Error fetching balance', { error, userId }) + return { + success: false, + message: 'Sorry, I couldn\'t fetch your balance. Please try again.' + } + } + } + + /** + * Handle deposit request + */ + private static async handleDeposit( + user: any, + intent: any + ): Promise { + try { + if (!intent.amount) { + return { + success: false, + message: 'Please specify an amount to deposit. Example: "deposit 100 USDC"' + } + } + + // For demo purposes, we'll simulate deposit processing + // In production, this would integrate with actual deposit logic + const depositData = { + amount: intent.amount.toString(), + currency: intent.currency || 'USDC', + walletAddress: user.walletAddress, + status: 'pending' + } + + return { + success: true, + message: formatDeposit(depositData) + } + } catch (error) { + logger.error('Error processing deposit', { error, userId: user.id }) + return { + success: false, + message: 'Sorry, I couldn\'t process your deposit request. Please try again.' + } + } + } + + /** + * Handle withdrawal request + */ + private static async handleWithdraw( + user: any, + intent: any + ): Promise { + try { + if (!intent.amount && !intent.all) { + return { + success: false, + message: 'Please specify an amount to withdraw or say "withdraw all". Example: "withdraw 50 USDC"' + } + } + + // Check available balance + const positions = await db.position.findMany({ + where: { + userId: user.id, + status: 'ACTIVE' + } + }) + + const totalBalance = positions.reduce((sum, pos) => + sum + Number(pos.currentValue), 0 + ) + + if (totalBalance === 0) { + return { + success: false, + message: 'You don\'t have any funds to withdraw.' + } + } + + const withdrawAmount = intent.all ? totalBalance : intent.amount + if (withdrawAmount > totalBalance) { + return { + success: false, + message: `Insufficient balance. You have $${totalBalance.toFixed(2)} available.` + } + } + + // For demo purposes, simulate withdrawal + const withdrawData = { + amount: withdrawAmount.toString(), + currency: 'USDC', // Assume USDC for simplicity + status: 'processing' + } + + return { + success: true, + message: formatWithdraw(withdrawData) + } + } catch (error) { + logger.error('Error processing withdrawal', { error, userId: user.id }) + return { + success: false, + message: 'Sorry, I couldn\'t process your withdrawal request. Please try again.' + } + } + } +} \ No newline at end of file diff --git a/src/whatsapp/userManager.ts b/src/whatsapp/userManager.ts new file mode 100644 index 0000000..f8f56c3 --- /dev/null +++ b/src/whatsapp/userManager.ts @@ -0,0 +1,180 @@ +import { Keypair } from '@stellar/stellar-sdk' +import { db } from '../db' +import { logger } from '../utils/logger' +import crypto from 'crypto' + +export interface WhatsAppUser { + id: string + phoneNumber: string + walletAddress: string + isVerified: boolean + isActive: boolean +} + +export class UserManager { + /** + * Find or create a user by phone number + */ + static async findOrCreateUser(phoneNumber: string): Promise { + try { + // Clean phone number (remove + and spaces) + const cleanPhone = phoneNumber.replace(/[\+\s]/g, '') + + let user = await db.user.findUnique({ + where: { phoneNumber: cleanPhone } + }) + + if (!user) { + // Create new Stellar wallet + const keypair = Keypair.random() + + // Encrypt the secret key (in production, use KMS) + const encryptedSecret = this.encryptSecret(keypair.secret()) + + user = await db.user.create({ + data: { + phoneNumber: cleanPhone, + walletAddress: keypair.publicKey(), + walletSecret: encryptedSecret, + network: 'TESTNET', // Use testnet for development + isActive: true, + isVerified: false + } + }) + + logger.info('Created new WhatsApp user', { + userId: user.id, + phoneNumber: cleanPhone, + walletAddress: user.walletAddress + }) + } + + return { + id: user.id, + phoneNumber: user.phoneNumber!, + walletAddress: user.walletAddress, + isVerified: user.isVerified, + isActive: user.isActive + } + } catch (error) { + logger.error('Error finding/creating WhatsApp user', { error, phoneNumber }) + throw error + } + } + + /** + * Generate and store OTP for user verification + */ + static async generateOTP(userId: string): Promise { + try { + // Generate 6-digit OTP + const otp = Math.floor(100000 + Math.random() * 900000).toString() + const expiresAt = new Date(Date.now() + 10 * 60 * 1000) // 10 minutes + + await db.user.update({ + where: { id: userId }, + data: { + otpCode: otp, + otpExpiresAt: expiresAt + } + }) + + logger.info('Generated OTP for user', { userId }) + return otp + } catch (error) { + logger.error('Error generating OTP', { error, userId }) + throw error + } + } + + /** + * Verify OTP code + */ + static async verifyOTP(userId: string, otpCode: string): Promise { + try { + const user = await db.user.findUnique({ + where: { id: userId }, + select: { + otpCode: true, + otpExpiresAt: true, + isVerified: true + } + }) + + if (!user || !user.otpCode || !user.otpExpiresAt) { + return false + } + + // Check if OTP is expired + if (new Date() > user.otpExpiresAt) { + return false + } + + // Check if OTP matches + if (user.otpCode !== otpCode) { + return false + } + + // Mark user as verified + await db.user.update({ + where: { id: userId }, + data: { + isVerified: true, + otpCode: null, + otpExpiresAt: null + } + }) + + logger.info('User verified with OTP', { userId }) + return true + } catch (error) { + logger.error('Error verifying OTP', { error, userId }) + throw error + } + } + + /** + * Get user's decrypted wallet secret (for signing transactions) + */ + static getWalletSecret(userId: string): Promise { + return db.user.findUnique({ + where: { id: userId }, + select: { walletSecret: true } + }).then(user => { + if (!user?.walletSecret) return null + return this.decryptSecret(user.walletSecret) + }) + } + + /** + * Encrypt wallet secret (MVP implementation - use KMS in production) + */ + private static encryptSecret(secret: string): string { + const algorithm = 'aes-256-cbc' + const key = crypto.scryptSync(process.env.ENCRYPTION_KEY || 'default-key', 'salt', 32) + const iv = crypto.randomBytes(16) + + const cipher = crypto.createCipheriv(algorithm, key, iv) + let encrypted = cipher.update(secret, 'utf8', 'hex') + encrypted += cipher.final('hex') + + return iv.toString('hex') + ':' + encrypted + } + + /** + * Decrypt wallet secret + */ + private static decryptSecret(encrypted: string): string { + const algorithm = 'aes-256-cbc' + const key = crypto.scryptSync(process.env.ENCRYPTION_KEY || 'default-key', 'salt', 32) + + const [ivHex, encryptedData] = encrypted.split(':') + const iv = Buffer.from(ivHex, 'hex') + + const decipher = crypto.createDecipheriv(algorithm, key, iv) + let decrypted = decipher.update(encryptedData, 'hex', 'utf8') + decrypted += decipher.final('utf8') + + return decrypted + } +} \ No newline at end of file diff --git a/tests/integration/api/whatsapp.test.ts b/tests/integration/api/whatsapp.test.ts new file mode 100644 index 0000000..7a6342c --- /dev/null +++ b/tests/integration/api/whatsapp.test.ts @@ -0,0 +1,165 @@ +import request from 'supertest' +import app from '../../../src/index' +import { db } from '../../../src/db' +import { UserManager } from '../../../src/whatsapp/userManager' + +describe('WhatsApp Webhook Integration Tests', () => { + const testPhoneNumber = '+1234567890' + let testUser: any + + beforeAll(async () => { + // Clean up any existing test data + await db.user.deleteMany({ + where: { phoneNumber: testPhoneNumber.replace('+', '') } + }) + }) + + afterAll(async () => { + // Clean up test data + if (testUser) { + await db.user.deleteMany({ + where: { phoneNumber: testPhoneNumber.replace('+', '') } + }) + } + }) + + describe('POST /api/whatsapp/webhook', () => { + it('should handle new user onboarding with OTP', async () => { + const response = await request(app) + .post('/api/whatsapp/webhook') + .set('Content-Type', 'application/x-www-form-urlencoded') + .send({ + From: `whatsapp:${testPhoneNumber}`, + Body: 'balance' + }) + + expect(response.status).toBe(200) + expect(response.type).toBe('text/xml') + expect(response.text).toContain('Welcome to NeuroWealth') + expect(response.text).toContain('Demo OTP') + }) + + it('should handle OTP verification', async () => { + // First get the user and generate OTP + testUser = await UserManager.findOrCreateUser(testPhoneNumber) + const otp = await UserManager.generateOTP(testUser.id) + + const response = await request(app) + .post('/api/whatsapp/webhook') + .set('Content-Type', 'application/x-www-form-urlencoded') + .send({ + From: `whatsapp:${testPhoneNumber}`, + Body: otp + }) + + expect(response.status).toBe(200) + expect(response.type).toBe('text/xml') + expect(response.text).toContain('Account Verified') + }) + + it('should handle balance inquiry for verified user', async () => { + const response = await request(app) + .post('/api/whatsapp/webhook') + .set('Content-Type', 'application/x-www-form-urlencoded') + .send({ + From: `whatsapp:${testPhoneNumber}`, + Body: 'balance' + }) + + expect(response.status).toBe(200) + expect(response.type).toBe('text/xml') + expect(response.text).toContain('Your Portfolio') + }) + + it('should handle deposit request', async () => { + const response = await request(app) + .post('/api/whatsapp/webhook') + .set('Content-Type', 'application/x-www-form-urlencoded') + .send({ + From: `whatsapp:${testPhoneNumber}`, + Body: 'deposit 100 USDC' + }) + + expect(response.status).toBe(200) + expect(response.type).toBe('text/xml') + expect(response.text).toContain('Deposit Instructions') + expect(response.text).toContain('100 USDC') + }) + + it('should handle withdrawal request', async () => { + const response = await request(app) + .post('/api/whatsapp/webhook') + .set('Content-Type', 'application/x-www-form-urlencoded') + .send({ + From: `whatsapp:${testPhoneNumber}`, + Body: 'withdraw 50 USDC' + }) + + expect(response.status).toBe(200) + expect(response.type).toBe('text/xml') + expect(response.text).toContain('Withdrawal Request') + }) + + it('should handle help command', async () => { + const response = await request(app) + .post('/api/whatsapp/webhook') + .set('Content-Type', 'application/x-www-form-urlencoded') + .send({ + From: `whatsapp:${testPhoneNumber}`, + Body: 'help' + }) + + expect(response.status).toBe(200) + expect(response.type).toBe('text/xml') + expect(response.text).toContain('NeuroWealth AI Agent') + expect(response.text).toContain('Available Commands') + }) + + it('should handle unknown commands', async () => { + const response = await request(app) + .post('/api/whatsapp/webhook') + .set('Content-Type', 'application/x-www-form-urlencoded') + .send({ + From: `whatsapp:${testPhoneNumber}`, + Body: 'unknown command' + }) + + expect(response.status).toBe(200) + expect(response.type).toBe('text/xml') + expect(response.text).toContain('NeuroWealth AI Agent') + }) + + it('should reject invalid OTP', async () => { + // Create a new unverified user for this test + const newPhone = '+0987654321' + await UserManager.findOrCreateUser(newPhone) + + const response = await request(app) + .post('/api/whatsapp/webhook') + .set('Content-Type', 'application/x-www-form-urlencoded') + .send({ + From: `whatsapp:${newPhone}`, + Body: '123456' // Invalid OTP + }) + + expect(response.status).toBe(200) + expect(response.type).toBe('text/xml') + expect(response.text).toContain('Invalid OTP') + + // Clean up + await db.user.deleteMany({ + where: { phoneNumber: newPhone.replace('+', '') } + }) + }) + }) + + describe('GET /api/whatsapp/webhook', () => { + it('should return health check', async () => { + const response = await request(app) + .get('/api/whatsapp/webhook') + + expect(response.status).toBe(200) + expect(response.text).toBe('WhatsApp webhook is active') + }) + }) +}) \ No newline at end of file From 79539dd5e24d2bb90eec9e03f26b7048f561bf8c Mon Sep 17 00:00:00 2001 From: Uyoxy Date: Fri, 6 Mar 2026 21:28:54 +0000 Subject: [PATCH 2/4] storage fix --- package-lock.json | 15 +++++++++++---- package.json | 3 ++- src/routes/deposit.ts | 2 +- src/routes/portfolio.ts | 14 +++++++------- src/routes/protocols.ts | 4 ++-- src/routes/transactions.ts | 4 ++-- src/routes/withdraw.ts | 2 +- src/whatsapp/handler.ts | 8 ++++---- src/whatsapp/userManager.ts | 2 +- 9 files changed, 31 insertions(+), 23 deletions(-) diff --git a/package-lock.json b/package-lock.json index 73f897d..0c0d331 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@prisma/client": "^5.22.0", "@stellar/stellar-sdk": "^14.5.0", "cors": "^2.8.6", + "decimal.js": "^10.4.3", "dotenv": "^17.3.1", "express": "^5.2.1", "express-rate-limit": "^8.2.1", @@ -1356,12 +1357,12 @@ } }, "node_modules/@stellar/stellar-sdk": { - "version": "14.5.0", - "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-14.5.0.tgz", - "integrity": "sha512-Uzjq+An/hUA+Q5ERAYPtT0+MMiwWnYYWMwozmZMjxjdL2MmSjucBDF8Q04db6K/ekU4B5cHuOfsdlrfaxQYblw==", + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-14.6.1.tgz", + "integrity": "sha512-A1rQWDLdUasXkMXnYSuhgep+3ZZzyuXJKdt5/KAIc0gkmSp906HTvUpbT4pu+bVr41tu0+J4Ugz9J4BQAGGytg==", "license": "Apache-2.0", "dependencies": { - "@stellar/stellar-base": "^14.0.4", + "@stellar/stellar-base": "^14.1.0", "axios": "^1.13.3", "bignumber.js": "^9.3.1", "commander": "^14.0.2", @@ -2964,6 +2965,12 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, "node_modules/dedent": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", diff --git a/package.json b/package.json index 89f0edb..546727f 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ "helmet": "^8.1.0", "twilio": "^5.12.2", "winston": "^3.19.0", - "zod": "^4.3.6" + "zod": "^4.3.6", + "decimal.js": "^10.4.3" }, "devDependencies": { "@types/cors": "^2.8.19", diff --git a/src/routes/deposit.ts b/src/routes/deposit.ts index 967c81b..4a9df74 100644 --- a/src/routes/deposit.ts +++ b/src/routes/deposit.ts @@ -4,7 +4,7 @@ import { db } from '../db' import { requireAuth } from '../middleware/auth' import { whatsappFormatters } from '../whatsapp/formatters' import { logger } from '../utils/logger' -import { Decimal } from '@prisma/client/runtime/library' +import { Decimal } from 'decimal.js' const router = Router() diff --git a/src/routes/portfolio.ts b/src/routes/portfolio.ts index f22c12d..2991ce6 100644 --- a/src/routes/portfolio.ts +++ b/src/routes/portfolio.ts @@ -4,7 +4,7 @@ import { db } from '../db' import { requireAuth } from '../middleware/auth' import { whatsappFormatters } from '../whatsapp/formatters' import { logger } from '../utils/logger' -import { Decimal } from '@prisma/client/runtime/library' +import { Decimal } from 'decimal.js' const router = Router() @@ -52,7 +52,7 @@ router.get('/:userId', requireAuth, async (req: Request, res: Response) => { let totalBalance = new Decimal(0) let totalYield = new Decimal(0) - positions.forEach((pos) => { + positions.forEach((pos: any) => { totalBalance = totalBalance.plus(pos.currentValue) totalYield = totalYield.plus(pos.yieldEarned) }) @@ -62,7 +62,7 @@ router.get('/:userId', requireAuth, async (req: Request, res: Response) => { totalBalance: totalBalance.toString(), totalYield: totalYield.toString(), positionCount: positions.length, - positions: positions.map((pos) => ({ + positions: positions.map((pos: any) => ({ id: pos.id, protocolName: pos.protocolName, assetSymbol: pos.assetSymbol, @@ -75,7 +75,7 @@ router.get('/:userId', requireAuth, async (req: Request, res: Response) => { whatsappReply: whatsappFormatters.formatPortfolio({ totalBalance: totalBalance.toString(), totalYield: totalYield.toString(), - positions: positions.map((pos) => ({ + positions: positions.map((pos: any) => ({ protocolName: pos.protocolName, assetSymbol: pos.assetSymbol, amount: pos.depositedAmount.toString(), @@ -145,7 +145,7 @@ router.get('/:userId/history', requireAuth, async (req: Request, res: Response) startDate, endDate: now, snapshotCount: snapshots.length, - snapshots: snapshots.map((snap) => ({ + snapshots: snapshots.map((snap: any) => ({ date: snap.snapshotAt, protocol: snap.position.protocolName, asset: snap.position.assetSymbol, @@ -210,7 +210,7 @@ router.get('/:userId/earnings', requireAuth, async (req: Request, res: Response) let totalPrincipal = new Decimal(0) const protocolBreakdown: Record = {} - positions.forEach((pos) => { + positions.forEach((pos: any) => { const yieldAmount = pos.yieldEarned totalEarned = totalEarned.plus(yieldAmount) totalPrincipal = totalPrincipal.plus(pos.depositedAmount) @@ -224,7 +224,7 @@ router.get('/:userId/earnings', requireAuth, async (req: Request, res: Response) const averageApy = positions.length > 0 ? positions - .reduce((sum, pos) => sum.plus(pos.yieldSnapshots[0]?.apy || new Decimal(0)), new Decimal(0)) + .reduce((sum: any, pos: any) => sum.plus(pos.yieldSnapshots[0]?.apy || new Decimal(0)), new Decimal(0)) .div(positions.length) .toString() : '0' diff --git a/src/routes/protocols.ts b/src/routes/protocols.ts index 984e776..e0860aa 100644 --- a/src/routes/protocols.ts +++ b/src/routes/protocols.ts @@ -29,7 +29,7 @@ router.get('/rates', async (req: Request, res: Response) => { // Group by protocol and asset const protocolMap: Record> = {} - rates.forEach((rate) => { + rates.forEach((rate: any) => { const key = rate.protocolName if (!protocolMap[key]) { protocolMap[key] = [] @@ -94,7 +94,7 @@ router.get('/agent/status', async (req: Request, res: Response) => { take: 100 }) - const successCount = recentLogs.filter((log) => log.status === 'SUCCESS').length + const successCount = recentLogs.filter((log: any) => log.status === 'SUCCESS').length const successRate = ((successCount / recentLogs.length) * 100).toFixed(1) const statusData = { diff --git a/src/routes/transactions.ts b/src/routes/transactions.ts index 44758e2..463fb40 100644 --- a/src/routes/transactions.ts +++ b/src/routes/transactions.ts @@ -72,7 +72,7 @@ router.get('/:userId', requireAuth, async (req: Request, res: Response) => { total, totalPages, hasMore: page < totalPages, - transactions: transactions.map((tx) => ({ + transactions: transactions.map((tx: any) => ({ id: tx.id, type: tx.type, assetSymbol: tx.assetSymbol, @@ -85,7 +85,7 @@ router.get('/:userId', requireAuth, async (req: Request, res: Response) => { memo: tx.memo })), whatsappReply: whatsappFormatters.formatTransactionHistory({ - transactions: transactions.map((tx) => ({ + transactions: transactions.map((tx: any) => ({ type: tx.type, assetSymbol: tx.assetSymbol, amount: tx.amount.toString(), diff --git a/src/routes/withdraw.ts b/src/routes/withdraw.ts index 269c67b..f264755 100644 --- a/src/routes/withdraw.ts +++ b/src/routes/withdraw.ts @@ -4,7 +4,7 @@ import { db } from '../db' import { requireAuth } from '../middleware/auth' import { whatsappFormatters } from '../whatsapp/formatters' import { logger } from '../utils/logger' -import { Decimal } from '@prisma/client/runtime/library' +import { Decimal } from 'decimal.js' const router = Router() diff --git a/src/whatsapp/handler.ts b/src/whatsapp/handler.ts index 8e3bd52..83206ec 100644 --- a/src/whatsapp/handler.ts +++ b/src/whatsapp/handler.ts @@ -121,15 +121,15 @@ export class WhatsAppHandler { } }) - const totalBalance = positions.reduce((sum, pos) => + const totalBalance = positions.reduce((sum: any, pos: any) => sum + Number(pos.currentValue), 0 ) - const totalYield = positions.reduce((sum, pos) => + const totalYield = positions.reduce((sum: any, pos: any) => sum + Number(pos.yieldEarned), 0 ) - const formattedPositions = positions.map(pos => ({ + const formattedPositions = positions.map((pos: any) => ({ protocolName: pos.protocolName, assetSymbol: pos.assetSymbol, amount: pos.depositedAmount.toString(), @@ -214,7 +214,7 @@ export class WhatsAppHandler { } }) - const totalBalance = positions.reduce((sum, pos) => + const totalBalance = positions.reduce((sum: any, pos: any) => sum + Number(pos.currentValue), 0 ) diff --git a/src/whatsapp/userManager.ts b/src/whatsapp/userManager.ts index f8f56c3..7e9284a 100644 --- a/src/whatsapp/userManager.ts +++ b/src/whatsapp/userManager.ts @@ -140,7 +140,7 @@ export class UserManager { return db.user.findUnique({ where: { id: userId }, select: { walletSecret: true } - }).then(user => { + }).then((user: any) => { if (!user?.walletSecret) return null return this.decryptSecret(user.walletSecret) }) From 5e9d3b24e14afe5470ed1cc650c0cb9bffe20bff Mon Sep 17 00:00:00 2001 From: Uyoxy Date: Fri, 6 Mar 2026 21:35:39 +0000 Subject: [PATCH 3/4] fix --- package-lock.json | 1 + package.json | 1 + src/config/jwt-adapter.ts | 6 ++---- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0c0d331..7b26e32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "express": "^5.2.1", "express-rate-limit": "^8.2.1", "helmet": "^8.1.0", + "jsonwebtoken": "^9.0.3", "twilio": "^5.12.2", "winston": "^3.19.0", "zod": "^4.3.6" diff --git a/package.json b/package.json index 546727f..d394c5f 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "express": "^5.2.1", "express-rate-limit": "^8.2.1", "helmet": "^8.1.0", + "jsonwebtoken": "^9.0.3", "twilio": "^5.12.2", "winston": "^3.19.0", "zod": "^4.3.6", diff --git a/src/config/jwt-adapter.ts b/src/config/jwt-adapter.ts index c8919a2..d826838 100644 --- a/src/config/jwt-adapter.ts +++ b/src/config/jwt-adapter.ts @@ -9,10 +9,8 @@ export class JwtAdapter { return new Promise((resolve) => { jwt.sign(payload, JWT_SEED, { expiresIn: `${durationInHours}h` - }, (error, token) => { - + }, (error: Error | null, token?: string) => { if (error) return resolve(null); - return resolve(token!); }) }); @@ -20,7 +18,7 @@ export class JwtAdapter { static validateToken(token: string): Promise { return new Promise((resolve) => { - jwt.verify(token, JWT_SEED, (error, decoded) => { + jwt.verify(token, JWT_SEED, (error: Error | null, decoded: object | undefined) => { if (error) return resolve(null); From 517c001a9285b41c9638113267de58616d3dec87 Mon Sep 17 00:00:00 2001 From: Uyoxy Date: Fri, 6 Mar 2026 21:44:16 +0000 Subject: [PATCH 4/4] lint passed sucessfully --- package-lock.json | 19 +++++++++++++++++++ package.json | 5 +++-- src/config/jwt-adapter.ts | 4 +--- src/whatsapp/handler.ts | 2 +- 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7b26e32..2620199 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/jest": "^30.0.0", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.3.0", "@types/supertest": "^7.2.0", "@types/twilio": "^3.19.2", @@ -1572,6 +1573,17 @@ "pretty-format": "^30.0.0" } }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -1579,6 +1591,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.3.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", diff --git a/package.json b/package.json index d394c5f..0793ad0 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@prisma/client": "^5.22.0", "@stellar/stellar-sdk": "^14.5.0", "cors": "^2.8.6", + "decimal.js": "^10.4.3", "dotenv": "^17.3.1", "express": "^5.2.1", "express-rate-limit": "^8.2.1", @@ -37,13 +38,13 @@ "jsonwebtoken": "^9.0.3", "twilio": "^5.12.2", "winston": "^3.19.0", - "zod": "^4.3.6", - "decimal.js": "^10.4.3" + "zod": "^4.3.6" }, "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/jest": "^30.0.0", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.3.0", "@types/supertest": "^7.2.0", "@types/twilio": "^3.19.2", diff --git a/src/config/jwt-adapter.ts b/src/config/jwt-adapter.ts index d826838..3305965 100644 --- a/src/config/jwt-adapter.ts +++ b/src/config/jwt-adapter.ts @@ -18,10 +18,8 @@ export class JwtAdapter { static validateToken(token: string): Promise { return new Promise((resolve) => { - jwt.verify(token, JWT_SEED, (error: Error | null, decoded: object | undefined) => { - + jwt.verify(token, JWT_SEED, (error, decoded) => { if (error) return resolve(null); - resolve(decoded as T); }) }); diff --git a/src/whatsapp/handler.ts b/src/whatsapp/handler.ts index 83206ec..69ca5b9 100644 --- a/src/whatsapp/handler.ts +++ b/src/whatsapp/handler.ts @@ -13,7 +13,7 @@ export interface IntentResult { export class WhatsAppHandler { /** - * Handle incoming WhatsApp message and return formatted response + * Handle incoming WhatsApp message and return formatted responses */ static async handleMessage( phoneNumber: string,