diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json
index 66e1ed6a..90b258fa 100644
--- a/apps/backend/package-lock.json
+++ b/apps/backend/package-lock.json
@@ -1208,6 +1208,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",
@@ -3273,6 +3274,7 @@
"resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-4.0.1.tgz",
"integrity": "sha512-68pFJgu+/AZbWkGu65Z3r55bTsCPlgyKaV4BSG8yUAD72q1PPuyVRgUwFv6BxdnibTUHlyxm06FmYWNC+bjN7A==",
"license": "MIT",
+ "peer": true,
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"axios": "^1.3.1",
@@ -3499,6 +3501,7 @@
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.17.tgz",
"integrity": "sha512-hLODw5Abp8OQgA+mUO4tHou4krKgDtUcM9j5Ihxncst9XeyxYBTt2bwZm4e4EQr5E352S4Fyy6V3iFx9ggxKAg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"file-type": "21.3.2",
"iterare": "1.2.1",
@@ -3558,6 +3561,7 @@
"integrity": "sha512-lD5mAYekTTurF3vDaa8C2OKPnjiz4tsfxIc5XlcSUzOhkwWf6Ay3HKvt6FmvuWQam6uIIHX52Clg+e6tAvf/cg==",
"hasInstallScript": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@nuxt/opencollective": "0.4.1",
"fast-safe-stringify": "2.1.1",
@@ -3641,6 +3645,7 @@
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.17.tgz",
"integrity": "sha512-mAf4eOsSBsTOn/VbrUO1gsjW6dVh91qqXPMXun4dN8SnNjf7PTQagM9o8d6ab8ZBpNe6UdZftdrZoDetU+n4Qg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"cors": "2.8.6",
"express": "5.2.1",
@@ -3662,6 +3667,7 @@
"resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.17.tgz",
"integrity": "sha512-BSOAsENdmTtsnDL0hb4takbWzPy9WoPybjlM57ab3/rQgm0biMFYUupH2uzmCjmmIXJL/EFbAWznVl8xw2Sa6Q==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"socket.io": "4.8.3",
"tslib": "2.8.1"
@@ -3878,6 +3884,7 @@
"resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-11.0.0.tgz",
"integrity": "sha512-SOeUQl70Lb2OfhGkvnh4KXWlsd+zA08RuuQgT7kKbzivngxzSo1Oc7Usu5VxCxACQC9wc2l9esOHILSJeK7rJA==",
"license": "MIT",
+ "peer": true,
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0",
@@ -3891,6 +3898,7 @@
"resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.17.tgz",
"integrity": "sha512-YbwQ0QfVj0lxkKQhdIIgk14ZSVWDqGk1J8nNSN6SLjf36sVv58Ma5ro+dtQua8wj3l2Ub7JJCVFixEhKtYc/rQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"iterare": "1.2.1",
"object-hash": "3.0.0",
@@ -5235,6 +5243,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -5514,6 +5523,7 @@
"integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.57.2",
"@typescript-eslint/types": "8.57.2",
@@ -5945,6 +5955,7 @@
"integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5993,6 +6004,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -6332,6 +6344,7 @@
"resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
"integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
@@ -6761,6 +6774,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -6839,6 +6853,7 @@
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.71.1.tgz",
"integrity": "sha512-kOBfdcsHmO6wwmIjpersoVdYQ7jkjTgky4Yop0loc7QwSdgxliSzD69U9ijZuRrkyCJwz5p5eqxeGeQkJ0YGZQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"cron-parser": "4.9.0",
"ioredis": "5.10.1",
@@ -6874,6 +6889,7 @@
"resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-7.2.8.tgz",
"integrity": "sha512-0HDaDLBBY/maa/LmUVAr70XUOwsiQD+jyzCBjmUErYZUKdMS9dT59PqW59PpVqfGM7ve6H0J6307JTpkCYefHQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@cacheable/utils": "^2.3.3",
"keyv": "^5.5.5"
@@ -6913,6 +6929,7 @@
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"cluster-key-slot": "1.1.2",
"generic-pool": "3.9.0",
@@ -7185,13 +7202,15 @@
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/class-validator": {
"version": "0.14.4",
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz",
"integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/validator": "^13.15.3",
"libphonenumber-js": "^1.11.1",
@@ -8079,14 +8098,14 @@
}
},
"node_modules/enhanced-resolve": {
- "version": "5.20.1",
- "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
- "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==",
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz",
+ "integrity": "sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
- "tapable": "^2.3.0"
+ "tapable": "^2.3.3"
},
"engines": {
"node": ">=10.13.0"
@@ -8189,12 +8208,11 @@
}
},
"node_modules/es-module-lexer": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
- "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
+ "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
"dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
@@ -8286,6 +8304,7 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8346,6 +8365,7 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -9570,7 +9590,6 @@
"resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz",
"integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==",
"license": "ISC",
- "peer": true,
"engines": {
"node": ">=4"
}
@@ -9581,7 +9600,6 @@
"integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==",
"deprecated": "this library is no longer supported",
"license": "MIT",
- "peer": true,
"dependencies": {
"ajv": "^6.12.3",
"har-schema": "^2.0.0"
@@ -10573,6 +10591,7 @@
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@jest/core": "^29.7.0",
"@jest/types": "^29.6.3",
@@ -11390,6 +11409,7 @@
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
"integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@keyv/serialize": "^1.1.1"
}
@@ -11461,9 +11481,9 @@
}
},
"node_modules/loader-runner": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz",
- "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz",
+ "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==",
"dev": true,
"license": "MIT",
"engines": {
@@ -12143,7 +12163,6 @@
"resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz",
"integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==",
"license": "Apache-2.0",
- "peer": true,
"engines": {
"node": "*"
}
@@ -12420,6 +12439,7 @@
"resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"passport-strategy": "1.x.x",
"pause": "0.0.1",
@@ -12559,6 +12579,7 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0",
@@ -12825,6 +12846,7 @@
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -13285,6 +13307,7 @@
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"cluster-key-slot": "1.1.2",
"generic-pool": "3.9.0",
@@ -13400,7 +13423,6 @@
"integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==",
"deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142",
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"aws-sign2": "~0.7.0",
"aws4": "^1.8.0",
@@ -13447,7 +13469,6 @@
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz",
"integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.6",
@@ -13462,7 +13483,6 @@
"resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz",
"integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"assert-plus": "^1.0.0",
"jsprim": "^1.2.2",
@@ -13478,7 +13498,6 @@
"resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz",
"integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"assert-plus": "1.0.0",
"extsprintf": "1.3.0",
@@ -13494,7 +13513,6 @@
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -13504,7 +13522,6 @@
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"mime-db": "1.52.0"
},
@@ -13517,7 +13534,6 @@
"resolved": "https://registry.npmjs.org/qs/-/qs-6.5.5.tgz",
"integrity": "sha512-mzR4sElr1bfCaPJe7m8ilJ6ZXdDaGoObcYR0ZHSsktM/Lt21MVHj5De30GQH2eiZ1qGRTO7LCAzQsUeXTNexWQ==",
"license": "BSD-3-Clause",
- "peer": true,
"engines": {
"node": ">=0.6"
}
@@ -13527,7 +13543,6 @@
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz",
"integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==",
"license": "BSD-3-Clause",
- "peer": true,
"dependencies": {
"psl": "^1.1.28",
"punycode": "^2.1.1"
@@ -13540,9 +13555,8 @@
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
- "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.",
+ "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
"license": "MIT",
- "peer": true,
"bin": {
"uuid": "bin/uuid"
}
@@ -14729,9 +14743,9 @@
}
},
"node_modules/tapable": {
- "version": "2.3.2",
- "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz",
- "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==",
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
+ "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -14771,9 +14785,9 @@
}
},
"node_modules/terser-webpack-plugin": {
- "version": "5.4.0",
- "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz",
- "integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==",
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.0.tgz",
+ "integrity": "sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -14793,12 +14807,39 @@
"webpack": "^5.1.0"
},
"peerDependenciesMeta": {
+ "@minify-html/node": {
+ "optional": true
+ },
"@swc/core": {
"optional": true
},
+ "@swc/css": {
+ "optional": true
+ },
+ "@swc/html": {
+ "optional": true
+ },
+ "clean-css": {
+ "optional": true
+ },
+ "cssnano": {
+ "optional": true
+ },
+ "csso": {
+ "optional": true
+ },
"esbuild": {
"optional": true
},
+ "html-minifier-terser": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "postcss": {
+ "optional": true
+ },
"uglify-js": {
"optional": true
}
@@ -14810,6 +14851,7 @@
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -15212,6 +15254,7 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@@ -15462,6 +15505,7 @@
"resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.28.tgz",
"integrity": "sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@sqltools/formatter": "^1.2.5",
"ansis": "^4.2.0",
@@ -15622,6 +15666,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -15914,14 +15959,13 @@
}
},
"node_modules/webpack": {
- "version": "5.105.4",
- "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz",
- "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==",
+ "version": "5.107.2",
+ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.107.2.tgz",
+ "integrity": "sha512-v7RhXaJbpMlV0D7hC7lb2EbnxkoeUqf9qhKr6lozx3Q48pmFrqqNRmZFUEGmi7pSwm6fCQ2H1IjvCkHqdpVdjQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
- "@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",
"@types/json-schema": "^7.0.15",
"@webassemblyjs/ast": "^1.14.1",
@@ -15931,21 +15975,20 @@
"acorn-import-phases": "^1.0.3",
"browserslist": "^4.28.1",
"chrome-trace-event": "^1.0.2",
- "enhanced-resolve": "^5.20.0",
- "es-module-lexer": "^2.0.0",
+ "enhanced-resolve": "^5.22.0",
+ "es-module-lexer": "^2.1.0",
"eslint-scope": "5.1.1",
"events": "^3.2.0",
"glob-to-regexp": "^0.4.1",
"graceful-fs": "^4.2.11",
- "json-parse-even-better-errors": "^2.3.1",
- "loader-runner": "^4.3.1",
- "mime-types": "^2.1.27",
+ "loader-runner": "^4.3.2",
+ "mime-db": "^1.54.0",
"neo-async": "^2.6.2",
"schema-utils": "^4.3.3",
"tapable": "^2.3.0",
- "terser-webpack-plugin": "^5.3.17",
+ "terser-webpack-plugin": "^5.5.0",
"watchpack": "^2.5.1",
- "webpack-sources": "^3.3.4"
+ "webpack-sources": "^3.5.0"
},
"bin": {
"webpack": "bin/webpack.js"
@@ -15974,9 +16017,9 @@
}
},
"node_modules/webpack-sources": {
- "version": "3.3.4",
- "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz",
- "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==",
+ "version": "3.5.0",
+ "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.0.tgz",
+ "integrity": "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -16003,7 +16046,6 @@
"integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=10.13.0"
},
@@ -16012,9 +16054,9 @@
}
},
"node_modules/webpack/node_modules/ajv": {
- "version": "8.18.0",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
- "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -16035,7 +16077,6 @@
"integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3"
},
@@ -16049,7 +16090,6 @@
"integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
"dev": true,
"license": "BSD-2-Clause",
- "peer": true,
"dependencies": {
"esrecurse": "^4.3.0",
"estraverse": "^4.1.1"
@@ -16064,7 +16104,6 @@
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
"dev": true,
"license": "BSD-2-Clause",
- "peer": true,
"engines": {
"node": ">=4.0"
}
@@ -16074,33 +16113,7 @@
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
- "license": "MIT",
- "peer": true
- },
- "node_modules/webpack/node_modules/mime-db": {
- "version": "1.52.0",
- "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
- "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/webpack/node_modules/mime-types": {
- "version": "2.1.35",
- "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
- "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "mime-db": "1.52.0"
- },
- "engines": {
- "node": ">= 0.6"
- }
+ "license": "MIT"
},
"node_modules/webpack/node_modules/schema-utils": {
"version": "4.3.3",
@@ -16108,7 +16121,6 @@
"integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/json-schema": "^7.0.9",
"ajv": "^8.9.0",
diff --git a/apps/backend/src/auth/auth.module.ts b/apps/backend/src/auth/auth.module.ts
index 1893574c..6d9fe017 100644
--- a/apps/backend/src/auth/auth.module.ts
+++ b/apps/backend/src/auth/auth.module.ts
@@ -1,4 +1,4 @@
-import { Module } from '@nestjs/common';
+import { Module, forwardRef } from '@nestjs/common';
import { JwtModule, JwtModuleOptions } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
@@ -16,7 +16,7 @@ import { RefreshToken } from './entities/refresh-token.entity';
@Module({
imports: [
TypeOrmModule.forFeature([User, PasswordResetToken, RefreshToken]),
- UsersModule,
+ forwardRef(() => UsersModule),
EmailModule,
PassportModule,
JwtModule.registerAsync({
diff --git a/apps/backend/src/auth/auth.service.ts b/apps/backend/src/auth/auth.service.ts
index f7ec87e7..ce9e2b98 100644
--- a/apps/backend/src/auth/auth.service.ts
+++ b/apps/backend/src/auth/auth.service.ts
@@ -193,6 +193,64 @@ export class AuthService {
};
}
+ /**
+ * Verify the signed challenge without creating a user session or issuing JWT.
+ * Used for secure account linking.
+ */
+ async verifyChallengeOnly(
+ publicKey: string,
+ signedChallenge: string,
+ ): Promise {
+ await Promise.resolve();
+ const storedChallenge = this.challengeStore.get(publicKey);
+
+ if (!storedChallenge) {
+ throw new BadRequestException(
+ 'No challenge found for this public key. Please request a new challenge.',
+ );
+ }
+
+ if (Date.now() > storedChallenge.expiresAt) {
+ this.challengeStore.delete(publicKey);
+ throw new BadRequestException(
+ 'Challenge has expired. Please request a new challenge.',
+ );
+ }
+
+ const networkPassphrase =
+ this.stellarNetwork === 'testnet' ? Networks.TESTNET : Networks.PUBLIC;
+
+ let transaction: Transaction;
+
+ try {
+ transaction = new Transaction(signedChallenge, networkPassphrase);
+ } catch {
+ this.challengeStore.delete(publicKey);
+ throw new BadRequestException('Invalid transaction format');
+ }
+
+ // Verify the transaction was signed by the user
+ const userSignature = transaction.signatures.find((sig) => {
+ try {
+ const keypair = Keypair.fromPublicKey(publicKey);
+ return keypair.verify(transaction.hash(), sig.signature());
+ } catch {
+ return false;
+ }
+ });
+
+ if (!userSignature) {
+ this.challengeStore.delete(publicKey);
+ throw new BadRequestException(
+ 'Invalid signature. Transaction was not signed by the provided public key.',
+ );
+ }
+
+ // Remove used challenge
+ this.challengeStore.delete(publicKey);
+ return true;
+ }
+
/**
* Verify the signed challenge and issue a JWT
*/
diff --git a/apps/backend/src/stellar/stellar.service.ts b/apps/backend/src/stellar/stellar.service.ts
index b9d3736d..0c1b36c1 100644
--- a/apps/backend/src/stellar/stellar.service.ts
+++ b/apps/backend/src/stellar/stellar.service.ts
@@ -160,16 +160,15 @@ export class StellarService {
): Promise {
validateStellarPublicKey(publicKey);
this.logger.debug(`Fetching transactions for account: ${publicKey}`);
-
try {
- const payments = await this.server
- .payments()
+ const operations = await this.server
+ .operations()
.forAccount(publicKey)
.order('desc')
.limit(limit)
.call();
- return payments.records;
+ return operations.records;
} catch (error: unknown) {
this.logger.error(`Error fetching transactions for ${publicKey}:`, error);
throw new HorizonUnavailableException(
diff --git a/apps/backend/src/users/dto/link-stellar-account.dto.ts b/apps/backend/src/users/dto/link-stellar-account.dto.ts
index 1e98063f..3565745f 100644
--- a/apps/backend/src/users/dto/link-stellar-account.dto.ts
+++ b/apps/backend/src/users/dto/link-stellar-account.dto.ts
@@ -22,4 +22,12 @@ export class LinkStellarAccountDto {
@IsOptional()
@MaxLength(100)
label?: string;
+
+ @ApiProperty({
+ description:
+ 'Signed challenge transaction XDR proving ownership of public key',
+ example: 'AAAAA...',
+ })
+ @IsString()
+ signedChallenge: string;
}
diff --git a/apps/backend/src/users/users.module.ts b/apps/backend/src/users/users.module.ts
index 3c462d16..9439d8dc 100644
--- a/apps/backend/src/users/users.module.ts
+++ b/apps/backend/src/users/users.module.ts
@@ -1,4 +1,4 @@
-import { Module } from '@nestjs/common';
+import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
@@ -6,9 +6,14 @@ import { User } from './entities/user.entity';
import { StellarAccount } from './entities/stellar-account.entity';
import { StellarService } from '../stellar/stellar.service';
import { UploadModule } from '../upload/upload.module';
+import { AuthModule } from '../auth/auth.module';
@Module({
- imports: [TypeOrmModule.forFeature([User, StellarAccount]), UploadModule],
+ imports: [
+ TypeOrmModule.forFeature([User, StellarAccount]),
+ UploadModule,
+ forwardRef(() => AuthModule),
+ ],
providers: [UsersService, StellarService],
controllers: [UsersController],
exports: [UsersService],
diff --git a/apps/backend/src/users/users.service.ts b/apps/backend/src/users/users.service.ts
index 418adfb2..4b05ba13 100644
--- a/apps/backend/src/users/users.service.ts
+++ b/apps/backend/src/users/users.service.ts
@@ -4,6 +4,8 @@ import {
BadRequestException,
ConflictException,
Logger,
+ Inject,
+ forwardRef,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
@@ -14,6 +16,7 @@ import { LinkStellarAccountDto } from './dto/link-stellar-account.dto';
import { StellarAccountResponseDto } from './dto/stellar-account-response.dto';
import { UpdateStellarAccountLabelDto } from './dto/update-stellar-account-label.dto';
import { UploadService } from '../upload/upload.service';
+import { AuthService } from '../auth/auth.service';
import crypto from 'crypto';
@Injectable()
@@ -27,6 +30,8 @@ export class UsersService {
private stellarAccountRepository: Repository,
private stellarService: StellarService,
private uploadService: UploadService,
+ @Inject(forwardRef(() => AuthService))
+ private authService: AuthService,
) {}
// --- BASIC CRUD ---
@@ -78,6 +83,12 @@ export class UsersService {
userId: string,
dto: LinkStellarAccountDto,
): Promise {
+ // Verify ownership via challenge-response signature first
+ await this.authService.verifyChallengeOnly(
+ dto.publicKey,
+ dto.signedChallenge,
+ );
+
this.stellarService.validatePublicKeyOrThrow(dto.publicKey);
const user = await this.usersRepository.findOne({ where: { id: userId } });
diff --git a/apps/data-processing/src/ingestion/news_fetcher.py b/apps/data-processing/src/ingestion/news_fetcher.py
index 0d3721bd..12715c8f 100644
--- a/apps/data-processing/src/ingestion/news_fetcher.py
+++ b/apps/data-processing/src/ingestion/news_fetcher.py
@@ -10,6 +10,7 @@
from dataclasses import dataclass, asdict
from .news_deduplicator import NewsDeduplicator
from datetime import datetime
+from src.utils.translator import translate_and_normalize
import requests
from requests.exceptions import RequestException, Timeout
@@ -115,64 +116,65 @@ def _fetch_cryptocompare(self, limit: int) -> List[NewsArticle]:
articles = []
try:
- self._respect_rate_limit()
-
- params = {
- "lang": "EN",
- "categories": "BTC,ETH,BLOCKCHAIN",
- "excludeCategories": "Sponsored",
- }
-
headers = {"Authorization": f"Apikey {self.cryptocompare_key}"}
- response = self.session.get(
- APIConfig.CRYPTOCOMPARE_URL,
- params=params,
- headers=headers,
- timeout=APIConfig.TIMEOUT,
- )
-
- if response.status_code != 200:
- self._handle_api_error(response, "CryptoCompare")
+ for lang in ["EN", "ES", "PT"]:
+ self._respect_rate_limit()
- data = response.json()
+ params = {
+ "lang": lang,
+ "categories": "BTC,ETH,BLOCKCHAIN",
+ "excludeCategories": "Sponsored",
+ }
- if data.get("Type") != 100:
- raise ValueError(
- f"CryptoCompare API returned error: {data.get('Message', 'Unknown error')}"
+ response = self.session.get(
+ APIConfig.CRYPTOCOMPARE_URL,
+ params=params,
+ headers=headers,
+ timeout=APIConfig.TIMEOUT,
)
- # Parse articles
- for item in data.get("Data", [])[:limit]:
- try:
- article = NewsArticle(
- id=f"cc_{item['id']}",
- title=item.get("title", ""),
- content=item.get("body", ""),
- summary=item.get("short_description", ""),
- source=item.get("source", "Unknown"),
- url=item.get("url", ""),
- published_at=datetime.fromtimestamp(
- item.get("published_on", 0)
- ),
- categories=(
- item.get("categories", "").split("|")
- if item.get("categories")
- else []
- ),
- tags=(
- item.get("tags", "").split("|") if item.get("tags") else []
- ),
- )
+ if response.status_code != 200:
+ self._handle_api_error(response, "CryptoCompare")
+
+ data = response.json()
- # Avoid duplicates
- if article.id not in self.seen_articles:
- articles.append(article)
- self.seen_articles.add(article.id)
+ if data.get("Type") != 100:
+ raise ValueError(
+ f"CryptoCompare API returned error: {data.get('Message', 'Unknown error')}"
+ )
- except KeyError as e:
- print(f"Warning: Missing key in CryptoCompare data: {e}")
- continue
+ # Parse articles
+ for item in data.get("Data", [])[:limit]:
+ try:
+ article = NewsArticle(
+ id=f"cc_{item['id']}",
+ title=translate_and_normalize(item.get("title", "")),
+ content=translate_and_normalize(item.get("body", "")),
+ summary=translate_and_normalize(item.get("short_description", "")),
+ source=item.get("source", "Unknown"),
+ url=item.get("url", ""),
+ published_at=datetime.fromtimestamp(
+ item.get("published_on", 0)
+ ),
+ categories=(
+ item.get("categories", "").split("|")
+ if item.get("categories")
+ else []
+ ),
+ tags=(
+ item.get("tags", "").split("|") if item.get("tags") else []
+ ),
+ )
+
+ # Avoid duplicates
+ if article.id not in self.seen_articles:
+ articles.append(article)
+ self.seen_articles.add(article.id)
+
+ except KeyError as e:
+ print(f"Warning: Missing key in CryptoCompare data: {e}")
+ continue
except RequestException as e:
print(f"Error fetching from CryptoCompare: {e}")
@@ -186,60 +188,61 @@ def _fetch_newsapi(self, limit: int) -> List[NewsArticle]:
articles = []
try:
- self._respect_rate_limit()
-
# Calculate date range (last 7 days for recent news)
to_date = datetime.now()
from_date = datetime.fromtimestamp(to_date.timestamp() - (7 * 24 * 3600))
- params = {
- "q": "cryptocurrency OR blockchain OR bitcoin OR ethereum",
- "language": "en",
- "sortBy": "publishedAt",
- "pageSize": min(limit, 100), # NewsAPI max is 100
- "from": from_date.strftime("%Y-%m-%d"),
- "to": to_date.strftime("%Y-%m-%d"),
- "apiKey": self.newsapi_key,
- }
-
- response = self.session.get(
- APIConfig.NEWSAPI_URL, params=params, timeout=APIConfig.TIMEOUT
- )
-
- if response.status_code != 200:
- self._handle_api_error(response, "NewsAPI")
-
- data = response.json()
-
- # Parse articles
- for item in data.get("articles", [])[:limit]:
- try:
- published_at = datetime.fromisoformat(
- item["publishedAt"].replace("Z", "+00:00")
- )
-
- article = NewsArticle(
- id=f"na_{hash(item['url']) & 0xFFFFFFFF}",
- title=item.get("title", ""),
- content=item.get("content", ""),
- summary=item.get("description", ""),
- source=item.get("source", {}).get("name", "Unknown"),
- url=item.get("url", ""),
- published_at=published_at,
- categories=[
- "crypto",
- "blockchain",
- ], # NewsAPI doesn't provide categories
- )
-
- # Avoid duplicates
- if article.id not in self.seen_articles:
- articles.append(article)
- self.seen_articles.add(article.id)
+ for lang in ["en", "es", "pt"]:
+ self._respect_rate_limit()
+
+ params = {
+ "q": "cryptocurrency OR blockchain OR bitcoin OR ethereum",
+ "language": lang,
+ "sortBy": "publishedAt",
+ "pageSize": min(limit, 100), # NewsAPI max is 100
+ "from": from_date.strftime("%Y-%m-%d"),
+ "to": to_date.strftime("%Y-%m-%d"),
+ "apiKey": self.newsapi_key,
+ }
+
+ response = self.session.get(
+ APIConfig.NEWSAPI_URL, params=params, timeout=APIConfig.TIMEOUT
+ )
- except (KeyError, ValueError) as e:
- print(f"Warning: Error parsing NewsAPI article: {e}")
- continue
+ if response.status_code != 200:
+ self._handle_api_error(response, "NewsAPI")
+
+ data = response.json()
+
+ # Parse articles
+ for item in data.get("articles", [])[:limit]:
+ try:
+ published_at = datetime.fromisoformat(
+ item["publishedAt"].replace("Z", "+00:00")
+ )
+
+ article = NewsArticle(
+ id=f"na_{hash(item['url']) & 0xFFFFFFFF}",
+ title=translate_and_normalize(item.get("title", "")),
+ content=translate_and_normalize(item.get("content", "")),
+ summary=translate_and_normalize(item.get("description", "")),
+ source=item.get("source", {}).get("name", "Unknown"),
+ url=item.get("url", ""),
+ published_at=published_at,
+ categories=[
+ "crypto",
+ "blockchain",
+ ], # NewsAPI doesn't provide categories
+ )
+
+ # Avoid duplicates
+ if article.id not in self.seen_articles:
+ articles.append(article)
+ self.seen_articles.add(article.id)
+
+ except (KeyError, ValueError) as e:
+ print(f"Warning: Error parsing NewsAPI article: {e}")
+ continue
except RequestException as e:
print(f"Error fetching from NewsAPI: {e}")
diff --git a/apps/data-processing/src/ingestion/social_fetcher.py b/apps/data-processing/src/ingestion/social_fetcher.py
index 9d68e91d..0ffb81ee 100644
--- a/apps/data-processing/src/ingestion/social_fetcher.py
+++ b/apps/data-processing/src/ingestion/social_fetcher.py
@@ -16,6 +16,7 @@
import requests
from requests.exceptions import RequestException
+from src.utils.translator import translate_and_normalize
logger = logging.getLogger(__name__)
@@ -237,7 +238,7 @@ def fetch_hashtag(
# Normalize hashtag
query = hashtag if hashtag.startswith("#") else f"#{hashtag}"
- query = f"{query} -is:retweet lang:en" # Exclude retweets, English only
+ query = f"{query} -is:retweet (lang:en OR lang:es OR lang:pt)" # Exclude retweets, English/Spanish/Portuguese
params = {
"query": query,
@@ -287,7 +288,7 @@ def fetch_hashtag(
post = SocialPost(
id=tweet["id"],
platform=SocialPlatform.TWITTER.value,
- content=tweet.get("text", ""),
+ content=translate_and_normalize(tweet.get("text", "")),
author=user.get("username", "unknown"),
posted_at=datetime.fromisoformat(tweet["created_at"].replace("Z", "+00:00")),
url=f"https://twitter.com/user/status/{tweet['id']}",
@@ -406,7 +407,7 @@ def fetch_subreddit(
post = SocialPost(
id=post_data.get("id", ""),
platform=SocialPlatform.REDDIT.value,
- content=post_data.get("selftext", "") or post_data.get("title", ""),
+ content=translate_and_normalize(post_data.get("selftext", "") or post_data.get("title", "")),
author=post_data.get("author", "[deleted]"),
posted_at=datetime.fromtimestamp(post_data.get("created_utc", time.time()), tz=timezone.utc),
url=f"https://reddit.com{post_data.get('permalink', '')}",
@@ -475,7 +476,7 @@ def fetch_search(
post = SocialPost(
id=post_data.get("id", ""),
platform=SocialPlatform.REDDIT.value,
- content=post_data.get("selftext", "") or post_data.get("title", ""),
+ content=translate_and_normalize(post_data.get("selftext", "") or post_data.get("title", "")),
author=post_data.get("author", "[deleted]"),
posted_at=datetime.fromtimestamp(post_data.get("created_utc", time.time()), tz=timezone.utc),
url=f"https://reddit.com{post_data.get('permalink', '')}",
diff --git a/apps/data-processing/src/utils/translator.py b/apps/data-processing/src/utils/translator.py
new file mode 100644
index 00000000..c8aa6b5e
--- /dev/null
+++ b/apps/data-processing/src/utils/translator.py
@@ -0,0 +1,88 @@
+import logging
+import unicodedata
+import requests
+from langdetect import detect
+
+logger = logging.getLogger(__name__)
+
+def normalize_text(text: str) -> str:
+ """
+ Applies NFKD unicode normalization, normalizes spacing, and strips text.
+ Keeps casing intact as it is valuable for sentiment analysis.
+ """
+ if not text:
+ return ""
+
+ # NFKD normalization decomposes characters (e.g. accented characters)
+ normalized = unicodedata.normalize("NFKD", text)
+
+ # Clean up whitespace and join
+ lines = normalized.splitlines()
+ cleaned_lines = []
+ for line in lines:
+ cleaned_words = " ".join(line.split())
+ if cleaned_words:
+ cleaned_lines.append(cleaned_words)
+
+ return "\n".join(cleaned_lines).strip()
+
+def translate_to_english(text: str, source_lang: str = "auto") -> str:
+ """
+ Translates non-English text to English using Google's public translation endpoint.
+ If the translation fails or times out, falls back to the original text.
+ """
+ if not text or not text.strip():
+ return text
+
+ url = "https://translate.googleapis.com/translate_a/single"
+ params = {
+ "client": "gtx",
+ "sl": source_lang,
+ "tl": "en",
+ "dt": "t",
+ "q": text
+ }
+
+ try:
+ response = requests.get(url, params=params, timeout=5)
+ response.raise_for_status()
+ data = response.json()
+
+ # Parse the translation chunks returned by Google Translate
+ if data and len(data) > 0 and data[0]:
+ translated_chunks = []
+ for chunk in data[0]:
+ if chunk and len(chunk) > 0 and chunk[0]:
+ translated_chunks.append(chunk[0])
+ if translated_chunks:
+ return "".join(translated_chunks)
+
+ except Exception as e:
+ logger.warning(f"Translation failed, falling back to original text. Error: {e}")
+
+ return text
+
+def translate_and_normalize(text: str) -> str:
+ """
+ Detects the language of the text. If it is not English, normalizes and
+ translates it to English. If it is English, just normalizes it.
+ """
+ if not text or not text.strip():
+ return ""
+
+ # 1. Normalize first (helpful for language detection)
+ normalized = normalize_text(text)
+
+ # 2. Detect language
+ try:
+ lang = detect(normalized)
+ except Exception:
+ # Default to English if detection fails (e.g. no letters)
+ lang = "en"
+
+ # 3. Translate if not English
+ if lang != "en":
+ logger.info(f"Detected language '{lang}'. Translating to English.")
+ return translate_to_english(normalized, source_lang=lang)
+
+ return normalized
diff --git a/apps/data-processing/tests/test_news_fetcher.py b/apps/data-processing/tests/test_news_fetcher.py
index cb216263..cd6630d2 100644
--- a/apps/data-processing/tests/test_news_fetcher.py
+++ b/apps/data-processing/tests/test_news_fetcher.py
@@ -83,8 +83,8 @@ def test_fetch_cryptocompare_success(self, mock_get):
self.assertEqual(articles[0].title, "Bitcoin Hits All-Time High")
self.assertEqual(articles[0].source, "CryptoNews")
- # Verify API call was made correctly
- mock_get.assert_called_once()
+ # Verify API calls were made correctly (for EN, ES, PT)
+ self.assertEqual(mock_get.call_count, 3)
call_args = mock_get.call_args
self.assertIn("Authorization", call_args[1]["headers"])
self.assertEqual(
diff --git a/apps/webapp/app/community/page.tsx b/apps/webapp/app/community/page.tsx
index 73921109..cc184be1 100644
--- a/apps/webapp/app/community/page.tsx
+++ b/apps/webapp/app/community/page.tsx
@@ -298,7 +298,7 @@ export default function CommunityPage() {
>
CONTRIBUTING.md
- . Here's the short version.
+ . Here's the short version.
{/* vertical line */}
diff --git a/apps/webapp/app/dashboard/page.tsx b/apps/webapp/app/dashboard/page.tsx
index 61ec14a2..a43eca36 100644
--- a/apps/webapp/app/dashboard/page.tsx
+++ b/apps/webapp/app/dashboard/page.tsx
@@ -81,47 +81,163 @@ export default function DashboardPage() {
- {/* Recent Transactions */}
-
-
- Recent Transactions
-
+ {/* Recent Stellar Operations */}
+
+
+
+
+
+
+ Recent Stellar Operations
+
+
+
+ Live Horizon feed
+
+
{!publicKey ? (
-
Connect your Stellar wallet to view transactions.
+
+
+
Connect your Stellar wallet to view recent account activity.
+
) : isLoadingStellar ? (
-
Loading transaction history...
- ) : !transactions || transactions.length === 0 ? (
-
No transactions found for this account.
- ) : (
-
- {transactions.map((tx) => (
-
-
-
- {tx.type.replace(/_/g, " ")}
-
-
- {new Date(tx.created_at).toLocaleString()}
-
-
-
- {tx.amount && (
-
- {parseFloat(tx.amount).toFixed(4)} {tx.asset_code || "XLM"}
-
- )}
-
- View on Explorer
-
+
+ {[1, 2, 3].map((i) => (
+
))}
+ ) : !transactions || transactions.length === 0 ? (
+
+
+
No recent activities found for this account.
+
+ ) : (
+
+ {transactions.map((tx: any) => {
+ let title = "Stellar Operation";
+ let desc = `Operation ID: ${tx.id.substring(0, 8)}...`;
+ let colorStyle = "text-gray-400 bg-gray-500/10 border-gray-500/20";
+ let iconSvg = (
+
+ );
+
+ const isOutgoing = tx.from === publicKey || tx.source_account === publicKey;
+ const isIncoming = tx.to === publicKey || tx.account === publicKey;
+
+ if (tx.type === "payment") {
+ if (isOutgoing) {
+ title = "Payment Sent";
+ desc = `To ${tx.to ? `${tx.to.substring(0, 6)}...${tx.to.substring(50)}` : "unknown"}`;
+ colorStyle = "text-rose-400 bg-rose-500/10 border-rose-500/20";
+ iconSvg = (
+
+ );
+ } else {
+ title = "Payment Received";
+ desc = `From ${tx.from ? `${tx.from.substring(0, 6)}...${tx.from.substring(50)}` : "unknown"}`;
+ colorStyle = "text-emerald-400 bg-emerald-500/10 border-emerald-500/20";
+ iconSvg = (
+
+ );
+ }
+ } else if (tx.type === "create_account") {
+ if (isIncoming) {
+ title = "Account Funded";
+ desc = `Funded with ${tx.starting_balance || "0"} XLM`;
+ colorStyle = "text-emerald-400 bg-emerald-500/10 border-emerald-500/20";
+ } else {
+ title = "Account Created";
+ desc = `Created account ${tx.account ? `${tx.account.substring(0, 6)}...` : ""}`;
+ colorStyle = "text-blue-400 bg-blue-500/10 border-blue-500/20";
+ }
+ iconSvg = (
+
+ );
+ } else if (tx.type === "change_trust") {
+ title = "Trustline Updated";
+ desc = `Asset: ${tx.asset_code || "Unknown"} | Limit: ${tx.limit ? parseFloat(tx.limit).toFixed(0) : "0"}`;
+ colorStyle = "text-cyan-400 bg-cyan-500/10 border-cyan-500/20";
+ iconSvg = (
+
+ );
+ } else if (tx.type === "set_options") {
+ title = "Settings Modified";
+ desc = "Account options / thresholds updated";
+ colorStyle = "text-amber-400 bg-amber-500/10 border-amber-500/20";
+ iconSvg = (
+
+ );
+ }
+
+ return (
+
+
+
+ {iconSvg}
+
+
+
+ {title}
+
+
+ {desc}
+
+
+
+
+ {tx.amount && (
+
+ {parseFloat(tx.amount).toFixed(4)} {tx.asset_code || "XLM"}
+
+ )}
+
+
+ {new Date(tx.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
+
+
+ Detail
+
+
+
+
+
+ );
+ })}
+
)}
diff --git a/apps/webapp/app/providers.tsx b/apps/webapp/app/providers.tsx
index 16786e2a..14d77cfb 100644
--- a/apps/webapp/app/providers.tsx
+++ b/apps/webapp/app/providers.tsx
@@ -49,13 +49,20 @@ function ConfigGate({ children }: { children: ReactNode }) {
);
}
+import { OnboardingProvider } from "@/lib/onboarding";
+import { ThemeProvider } from "@/components/theme-provider";
+
export function Providers({ children }: { children: ReactNode }) {
return (
- {children}
+
+
+ {children}
+
+
diff --git a/apps/webapp/components/crypto-table.tsx b/apps/webapp/components/crypto-table.tsx
index 61bd5e67..49c736e4 100644
--- a/apps/webapp/components/crypto-table.tsx
+++ b/apps/webapp/components/crypto-table.tsx
@@ -31,7 +31,9 @@ export function CryptoTable({ formatNumberAction, showWatchlistToggle = true }:
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [favorites, setFavorites] = useState([]);
- const { toggleItem, isInWatchlist } = showWatchlistToggle ? useWatchlist() : { toggleItem: async () => ({ added: false }), isInWatchlist: () => false };
+ const watchlist = useWatchlist();
+ const toggleItem = showWatchlistToggle ? watchlist.toggleItem : async () => ({ added: false });
+ const isInWatchlist = showWatchlistToggle ? watchlist.isInWatchlist : () => false;
// Fetch real crypto data
useEffect(() => {
diff --git a/apps/webapp/components/onboarding/OnboardingModal.tsx b/apps/webapp/components/onboarding/OnboardingModal.tsx
index e6505ae0..58ea44b9 100644
--- a/apps/webapp/components/onboarding/OnboardingModal.tsx
+++ b/apps/webapp/components/onboarding/OnboardingModal.tsx
@@ -1,8 +1,11 @@
"use client";
-import { useEffect, useRef, useCallback } from 'react';
+import { useEffect, useRef, useCallback, useState } from 'react';
import { useOnboarding } from '@/lib/onboarding';
import { useStellarWallet } from '@/app/providers';
+import { useStellarConfig } from '@/contexts/StellarConfigContext';
+import { signTransaction } from '@stellar/freighter-api';
+import { StellarApiService } from '@/lib/api-services';
import Link from 'next/link';
import dynamic from 'next/dynamic';
import gsap from 'gsap';
@@ -36,10 +39,14 @@ const steps = [
export default function OnboardingModal() {
const { state, nextStep, prevStep, closeOnboarding, completeOnboarding, skipOnboarding } = useOnboarding();
- const { status: walletStatus, connect: connectWallet } = useStellarWallet();
+ const { status: walletStatus, connect: connectWallet, publicKey } = useStellarWallet();
+ const { config } = useStellarConfig();
const modalRef = useRef(null);
const stepRef = useRef(null);
+ const [linkingState, setLinkingState] = useState<'idle' | 'fetching_challenge' | 'signing' | 'linking' | 'success' | 'error'>('idle');
+ const [linkingError, setLinkingError] = useState(null);
+
// Auto-advance logic (optional, lightweight)
useEffect(() => {
if (state.step >= 1 && state.step <= 3) {
@@ -70,13 +77,48 @@ export default function OnboardingModal() {
}
}, [state.step]);
+ const handleLinkWallet = useCallback(async () => {
+ if (!publicKey) return;
+ setLinkingState('fetching_challenge');
+ setLinkingError(null);
+ try {
+ const challengeRes = await StellarApiService.getChallenge(publicKey);
+ const challengeXDR = challengeRes.challenge;
+
+ setLinkingState('signing');
+ const networkPassphrase = config?.networkPassphrase;
+ const result = await signTransaction(challengeXDR, { networkPassphrase });
+
+ if (result.error) {
+ throw new Error(result.error);
+ }
+
+ setLinkingState('linking');
+ await StellarApiService.linkAccount(publicKey, result.signedTxXdr, "Onboarding Wallet");
+
+ setLinkingState('success');
+ setTimeout(() => {
+ nextStep();
+ setLinkingState('idle');
+ }, 1500);
+ } catch (err: any) {
+ console.error('Wallet linking error:', err);
+ setLinkingState('error');
+ setLinkingError(err.message || 'Failed to sign or verify challenge.');
+ }
+ }, [publicKey, config, nextStep]);
+
const handleNextOrConnect = useCallback(() => {
- if (state.step === 2 && walletStatus !== 'connected') {
- connectWallet();
+ if (state.step === 2) {
+ if (walletStatus !== 'connected') {
+ connectWallet();
+ } else {
+ handleLinkWallet();
+ }
} else {
nextStep();
}
- }, [state.step, walletStatus, connectWallet, nextStep]);
+ }, [state.step, walletStatus, connectWallet, nextStep, handleLinkWallet]);
const handlePrimaryAction = useCallback(() => {
if (state.step === 3) {
@@ -134,8 +176,15 @@ export default function OnboardingModal() {
/>
+ {/* Link Error Message */}
+ {state.step === 2 && (linkingState === 'error' || linkingError) && (
+
+ {linkingError || "An error occurred during wallet verification."}
+
+ )}
+
{/* Skip Button */}
- {state.step < 3 && (
+ {state.step < 3 && linkingState === 'idle' && (