diff --git a/backend/nest-cli.json b/backend/nest-cli.json index f9aa683..3e472d0 100644 --- a/backend/nest-cli.json +++ b/backend/nest-cli.json @@ -3,6 +3,13 @@ "collection": "@nestjs/schematics", "sourceRoot": "src", "compilerOptions": { - "deleteOutDir": true + "deleteOutDir": true, + "assets": [ + { + "include": "module/i18n/locales/**/*", + "outDir": "dist" + } + ], + "watchAssets": true } } diff --git a/backend/package-lock.json b/backend/package-lock.json index 125fda8..d4b51ad 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -35,6 +35,7 @@ "ioredis": "^5.10.1", "joi": "^17.13.3", "nest-winston": "^1.10.2", + "nestjs-i18n": "^10.8.4", "nodemailer": "^7.0.12", "passport": "^0.7.0", "passport-github2": "^0.1.12", @@ -2038,6 +2039,50 @@ "node": ">=8" } }, + "node_modules/@messageformat/core": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@messageformat/core/-/core-3.4.0.tgz", + "integrity": "sha512-NgCFubFFIdMWJGN5WuQhHCNmzk7QgiVfrViFxcS99j7F5dDS5EP6raR54I+2ydhe4+5/XTn/YIEppFaqqVWHsw==", + "license": "MIT", + "dependencies": { + "@messageformat/date-skeleton": "^1.0.0", + "@messageformat/number-skeleton": "^1.0.0", + "@messageformat/parser": "^5.1.0", + "@messageformat/runtime": "^3.0.1", + "make-plural": "^7.0.0", + "safe-identifier": "^0.4.1" + } + }, + "node_modules/@messageformat/date-skeleton": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@messageformat/date-skeleton/-/date-skeleton-1.1.0.tgz", + "integrity": "sha512-rmGAfB1tIPER+gh3p/RgA+PVeRE/gxuQ2w4snFWPF5xtb5mbWR7Cbw7wCOftcUypbD6HVoxrVdyyghPm3WzP5A==", + "license": "MIT" + }, + "node_modules/@messageformat/number-skeleton": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@messageformat/number-skeleton/-/number-skeleton-1.2.0.tgz", + "integrity": "sha512-xsgwcL7J7WhlHJ3RNbaVgssaIwcEyFkBqxHdcdaiJzwTZAWEOD8BuUFxnxV9k5S0qHN3v/KzUpq0IUpjH1seRg==", + "license": "MIT" + }, + "node_modules/@messageformat/parser": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@messageformat/parser/-/parser-5.1.1.tgz", + "integrity": "sha512-3p0YRGCcTUCYvBKLIxtDDyrJ0YijGIwrTRu1DT8gIviIDZru8H23+FkY6MJBzM1n9n20CiM4VeDYuBsrrwnLjg==", + "license": "MIT", + "dependencies": { + "moo": "^0.5.1" + } + }, + "node_modules/@messageformat/runtime": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@messageformat/runtime/-/runtime-3.0.2.tgz", + "integrity": "sha512-dkIPDCjXcfhSHgNE1/qV6TeczQZR59Yx0xXeafVKgK3QVWoxc38ljwpksUpnzCGvN151KUbCJTDZVmahtf1YZw==", + "license": "MIT", + "dependencies": { + "make-plural": "^7.0.0" + } + }, "node_modules/@microsoft/tsdoc": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz", @@ -2334,7 +2379,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.19.tgz", "integrity": "sha512-qeiTt2tv+e5QyDKqG8HlVZb2wx64FEaSGFJouqTSRs+kG44iTfl3xlz1XqVped+rihx4hmjWgL5gkhtdK3E6+Q==", "license": "MIT", - "peer": true, "dependencies": { "file-type": "21.3.4", "iterare": "1.2.1", @@ -2382,7 +2426,6 @@ "integrity": "sha512-6nJkWa2efrYi+XlU686J9y5L7OvxpLVjT0T/sxRKE7Jvpffiihelup4WSvLvRhdHDjj/5SuoWEwqReXAaaeHmw==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", @@ -2466,7 +2509,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.19.tgz", "integrity": "sha512-Vpdv8jyCQdThfoTx+UTn+DRYr6H6X02YUqcpZ3qP6G3ZUwtVp7eS+hoQPGd4UuCnlnFG8Wqr2J9bGEzQdi1rIg==", "license": "MIT", - "peer": true, "dependencies": { "cors": "2.8.6", "express": "5.2.1", @@ -2488,7 +2530,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.19.tgz", "integrity": "sha512-gu1nPIEaP5Qjjg/Cl8wXyvwGpdZGzgbtK4KcH65YRAA+GTKUkIHb4BNpLJ27Ymq/wqLJKNEbCjajfzD0BEjMGA==", "license": "MIT", - "peer": true, "dependencies": { "socket.io": "4.8.3", "tslib": "2.8.1" @@ -2686,7 +2727,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-11.0.1.tgz", "integrity": "sha512-8rw/nKT0S+L+MkzgE9F2/mox7mAgsPlwfzmW9gsESN1lmQtIrVEfiiBwC2O8+guS1jBfQehJIdcdUj2OAp4VUQ==", "license": "MIT", - "peer": true, "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "@nestjs/core": "^10.0.0 || ^11.0.0", @@ -2700,7 +2740,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.19.tgz", "integrity": "sha512-2qo8jtIwwwgkqAI1BtnJ02EaFLrRkKA39eYXS8IhZCHilhBHCWdjnJ5cLcFq4oF+s+KZ7LcLGD/3stxJy8ijzg==", "license": "MIT", - "peer": true, "dependencies": { "iterare": "1.2.1", "object-hash": "3.0.0", @@ -2854,7 +2893,6 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz", "integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==", "license": "MIT", - "peer": true, "dependencies": { "cluster-key-slot": "1.1.2" }, @@ -3204,7 +3242,6 @@ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -3359,7 +3396,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3610,7 +3646,6 @@ "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/types": "8.59.1", @@ -4010,7 +4045,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4060,7 +4094,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -4188,7 +4221,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -4202,7 +4234,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -4697,6 +4728,18 @@ "node": "*" } }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -4794,7 +4837,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -4841,7 +4883,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -5042,7 +5083,6 @@ "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", @@ -5308,15 +5348,13 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "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", @@ -6369,7 +6407,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6426,7 +6463,6 @@ "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -6937,7 +6973,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -7260,7 +7295,6 @@ "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, @@ -7425,6 +7459,7 @@ "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=8" }, @@ -7840,6 +7875,18 @@ "dev": true, "license": "MIT" }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -7872,7 +7919,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7901,7 +7947,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -7924,7 +7969,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -8121,7 +8165,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -9453,6 +9496,12 @@ "devOptional": true, "license": "ISC" }, + "node_modules/make-plural": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/make-plural/-/make-plural-7.5.0.tgz", + "integrity": "sha512-0booA+aVYyVFoR67JBHdfVk0U08HmrBH2FrtmBqBa+NldlqXv/G2Z9VQuQq6Wgp2jDWdybEWGfBkk1cq5264WA==", + "license": "Unicode-DFS-2016" + }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -9644,6 +9693,12 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/moo": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.3.tgz", + "integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==", + "license": "BSD-3-Clause" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -9655,6 +9710,7 @@ "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.10.tgz", "integrity": "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA==", "license": "MIT", + "peer": true, "optionalDependencies": { "msgpackr-extract": "^3.0.2" } @@ -9789,6 +9845,109 @@ "winston": "^3.0.0" } }, + "node_modules/nestjs-i18n": { + "version": "10.8.4", + "resolved": "https://registry.npmjs.org/nestjs-i18n/-/nestjs-i18n-10.8.4.tgz", + "integrity": "sha512-CJAp+IvaWQaCHi7jI1aej5D4S4uJIIGgqaYT8TrMAW0u3JzLS31FPFMi6OFo7LLcaSgQ7uKPyEOYZe6A3i+N4w==", + "license": "MIT", + "dependencies": { + "@messageformat/core": "^3.4.0", + "chokidar": "^3.6.0", + "cookie": "^1.1.1", + "string-format": "^2.0.0", + "yaml": "^2.8.3" + }, + "bin": { + "nestjs-i18n": "bin/nestjs-i18n.mjs" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@nestjs/common": "*", + "@nestjs/core": "*", + "class-validator": "*", + "rxjs": "*" + }, + "peerDependenciesMeta": { + "class-validator": { + "optional": true + } + } + }, + "node_modules/nestjs-i18n/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/nestjs-i18n/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/nestjs-i18n/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/nestjs-i18n/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/nestjs-i18n/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/node-abort-controller": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", @@ -10109,7 +10268,6 @@ "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", @@ -10307,7 +10465,6 @@ "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", @@ -10573,7 +10730,6 @@ "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -10839,7 +10995,6 @@ "resolved": "https://registry.npmjs.org/redis/-/redis-5.12.1.tgz", "integrity": "sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==", "license": "MIT", - "peer": true, "dependencies": { "@redis/bloom": "5.12.1", "@redis/client": "5.12.1", @@ -11152,6 +11307,12 @@ ], "license": "MIT" }, + "node_modules/safe-identifier": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/safe-identifier/-/safe-identifier-0.4.2.tgz", + "integrity": "sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w==", + "license": "ISC" + }, "node_modules/safe-stable-stringify": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", @@ -11204,7 +11365,6 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -11714,6 +11874,12 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-format": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/string-format/-/string-format-2.0.0.tgz", + "integrity": "sha512-bbEs3scLeYNXLecRRuk6uJxdXUSj6le/8rNPHChIJTn2V79aXVTR1EH2OH5zLKKoz0V02fOUKZZcw01pLUShZA==", + "license": "WTFPL OR MIT" + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -12224,7 +12390,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -12390,7 +12555,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -12556,7 +12720,6 @@ "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", @@ -12782,7 +12945,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13161,6 +13323,7 @@ "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -13179,6 +13342,7 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -13193,6 +13357,7 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -13203,6 +13368,7 @@ "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -13270,7 +13436,6 @@ "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", "license": "MIT", - "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", @@ -13440,6 +13605,21 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/backend/package.json b/backend/package.json index e5bdd22..ce35cde 100644 --- a/backend/package.json +++ b/backend/package.json @@ -46,6 +46,7 @@ "ioredis": "^5.10.1", "joi": "^17.13.3", "nest-winston": "^1.10.2", + "nestjs-i18n": "^10.8.4", "nodemailer": "^7.0.12", "passport": "^0.7.0", "passport-github2": "^0.1.12", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 9b380b8..ed15326 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -7,9 +7,12 @@ import { AppController } from './app.controller'; import { AppService } from './app.service'; import { AuthModule } from './auth/auth.module'; import { buildWinstonOptions } from './common/logger.config'; +import { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { LoggerMiddleware } from './common/middleware/logger.middleware'; import { DocumentsModule } from './documents/documents.module'; import { MailModule } from './mail/mail.module'; +import { I18nModule } from './module/i18n/i18n.module'; +import { UserProfileModule } from './module/user-profile/user-profile.module'; import { UserActivityModule } from './module/user-activity/user-activity.module'; import { QueueModule } from './queue/queue.module'; import { RiskAssessmentModule } from './risk-assessment/risk-assessment.module'; @@ -53,6 +56,8 @@ import { ConfigValidationSchema } from './config/config.validation'; UsersModule, AuthModule, DocumentsModule, + I18nModule, + UserProfileModule, PublicVerificationModule, UserActivityModule, RiskAssessmentModule, @@ -62,7 +67,7 @@ import { ConfigValidationSchema } from './config/config.validation'; QueueModule, ], controllers: [AppController], - providers: [AppService, LoggerMiddleware], + providers: [AppService, LoggerMiddleware, HttpExceptionFilter], }) export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer) { diff --git a/backend/src/common/filters/http-exception.filter.ts b/backend/src/common/filters/http-exception.filter.ts index b1c67c4..a8b6ccb 100644 --- a/backend/src/common/filters/http-exception.filter.ts +++ b/backend/src/common/filters/http-exception.filter.ts @@ -1,4 +1,4 @@ -import { +import { ArgumentsHost, Catch, ExceptionFilter, @@ -6,13 +6,19 @@ HttpStatus, Logger, } from '@nestjs/common'; -import { Response, Request } from 'express'; +import { ConfigService } from '@nestjs/config'; +import { Request, Response } from 'express'; + +import { MultiLanguageSupportService } from '../../module/i18n/multi-language-support.service'; @Catch() export class HttpExceptionFilter implements ExceptionFilter { private readonly logger = new Logger(HttpExceptionFilter.name); - constructor(private readonly isProduction = false) {} + constructor( + private readonly languageService: MultiLanguageSupportService, + private readonly configService: ConfigService, + ) {} catch(exception: unknown, host: ArgumentsHost) { const ctx = host.switchToHttp(); @@ -26,9 +32,15 @@ export class HttpExceptionFilter implements ExceptionFilter { const errorResponse = isHttp ? exception.getResponse() - : { message: (exception as Error)?.message }; // fallback message + : { message: (exception as Error)?.message }; - const { message, error } = this.normalizeResponse(errorResponse, exception); + const languageCode = this.resolveLanguage(request); + const { message, error } = this.normalizeResponse( + errorResponse, + exception, + status, + languageCode, + ); const payload = { statusCode: status, @@ -38,12 +50,12 @@ export class HttpExceptionFilter implements ExceptionFilter { path: request.url, }; - this.logger.error( - `${status} -> `, - (exception as Error)?.stack, - ); + this.logger.error(`${status} -> `, (exception as Error)?.stack); - if (!this.isProduction && exception instanceof Error) { + if ( + this.configService.get('NODE_ENV') !== 'production' && + exception instanceof Error + ) { Object.assign(payload, { stack: exception.stack }); } @@ -53,29 +65,77 @@ export class HttpExceptionFilter implements ExceptionFilter { private normalizeResponse( response: string | object | null | undefined, exception: unknown, + status: HttpStatus, + languageCode: string, ) { - let message = 'Internal server error'; + let message = this.translateStatus(status, languageCode); let error = HttpStatus.INTERNAL_SERVER_ERROR.toString(); if (typeof response === 'string') { - message = response; + message = message || response; } else if (response && typeof response === 'object') { const body = response as Record; - if (body.message) { - message = Array.isArray(body.message) - ? body.message.join(', ') - : body.message; - } else if (exception instanceof Error && exception.message) { - message = exception.message; + if (!message) { + if (body.message) { + message = Array.isArray(body.message) + ? body.message.join(', ') + : body.message; + } else if (exception instanceof Error && exception.message) { + message = exception.message; + } } if (body.error) { error = body.error; } - } else if (exception instanceof Error) { + } else if (exception instanceof Error && !message) { message = exception.message; } return { message, error }; } + + private resolveLanguage(request: Request) { + const user = ( + request as Request & { + user?: { preferredLanguage?: string }; + } + ).user; + const preferredLanguage = user?.preferredLanguage; + + if ( + preferredLanguage && + this.languageService.isSupported(preferredLanguage) + ) { + return preferredLanguage; + } + + return 'en'; + } + + private translateStatus(status: HttpStatus, languageCode: string) { + if (status === HttpStatus.BAD_REQUEST) { + return this.languageService.translate( + 'errors.validationFailed', + languageCode, + ); + } + + if (status === HttpStatus.FORBIDDEN) { + return this.languageService.translate('errors.forbidden', languageCode); + } + + if (status === HttpStatus.NOT_FOUND) { + return this.languageService.translate('errors.notFound', languageCode); + } + + if (status === HttpStatus.INTERNAL_SERVER_ERROR) { + return this.languageService.translate( + 'errors.internalServerError', + languageCode, + ); + } + + return ''; + } } diff --git a/backend/src/main.ts b/backend/src/main.ts index 25997b9..2244fd7 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -38,11 +38,7 @@ async function bootstrap() { }), ); - app.useGlobalFilters( - new HttpExceptionFilter( - configService.get('NODE_ENV') === 'production', - ), - ); + app.useGlobalFilters(app.get(HttpExceptionFilter)); // Swagger documentation const config = new DocumentBuilder() diff --git a/backend/src/module/i18n/i18n.module.ts b/backend/src/module/i18n/i18n.module.ts new file mode 100644 index 0000000..a25b50e --- /dev/null +++ b/backend/src/module/i18n/i18n.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { I18nModule as NestI18nModule } from 'nestjs-i18n'; +import { AcceptLanguageResolver, I18nJsonLoader } from 'nestjs-i18n'; +import { join } from 'path'; + +import { MultiLanguageSupportService } from './multi-language-support.service'; + +@Module({ + imports: [ + NestI18nModule.forRoot({ + fallbackLanguage: 'en', + loader: I18nJsonLoader, + loaderOptions: { + path: join(__dirname, 'locales'), + watch: true, + }, + resolvers: [new AcceptLanguageResolver({ matchType: 'strict-loose' })], + logging: false, + }), + ], + providers: [MultiLanguageSupportService], + exports: [MultiLanguageSupportService], +}) +export class I18nModule {} diff --git a/backend/src/module/i18n/locales/en/en.json b/backend/src/module/i18n/locales/en/en.json new file mode 100644 index 0000000..693e3af --- /dev/null +++ b/backend/src/module/i18n/locales/en/en.json @@ -0,0 +1,12 @@ +{ + "errors": { + "validationFailed": "Validation failed", + "forbidden": "Forbidden", + "notFound": "Not found", + "internalServerError": "Internal server error", + "unsupportedLanguage": "Unsupported language code" + }, + "language": { + "updated": "Language preference updated" + } +} diff --git a/backend/src/module/i18n/locales/fr/fr.json b/backend/src/module/i18n/locales/fr/fr.json new file mode 100644 index 0000000..96deb13 --- /dev/null +++ b/backend/src/module/i18n/locales/fr/fr.json @@ -0,0 +1,12 @@ +{ + "errors": { + "validationFailed": "La validation a échoué", + "forbidden": "Interdit", + "notFound": "Introuvable", + "internalServerError": "Erreur interne du serveur", + "unsupportedLanguage": "Code de langue non pris en charge" + }, + "language": { + "updated": "Préférence de langue mise à jour" + } +} diff --git a/backend/src/module/i18n/multi-language-support.service.ts b/backend/src/module/i18n/multi-language-support.service.ts new file mode 100644 index 0000000..82feaf5 --- /dev/null +++ b/backend/src/module/i18n/multi-language-support.service.ts @@ -0,0 +1,96 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { I18nService } from 'nestjs-i18n'; +import { readFile, readdir } from 'fs/promises'; +import { join } from 'path'; + +type LocaleData = Record; + +@Injectable() +export class MultiLanguageSupportService implements OnModuleInit { + private readonly localesPath = join(__dirname, 'locales'); + private supportedLanguages: string[] = ['en']; + + constructor(private readonly i18nService: I18nService) {} + + async onModuleInit() { + await this.validateLocaleFiles(); + } + + translate(key: string, lang: string) { + return this.i18nService.translate(key, { lang }); + } + + isSupported(languageCode: string) { + return this.supportedLanguages.includes(languageCode); + } + + getSupportedLanguages() { + return [...this.supportedLanguages]; + } + + private async validateLocaleFiles() { + const entries = await readdir(this.localesPath, { withFileTypes: true }); + const localeDirs = entries.filter((entry) => entry.isDirectory()); + + this.supportedLanguages = localeDirs.map((entry) => entry.name); + + if (!this.supportedLanguages.includes('en')) { + throw new Error('English locale is required'); + } + + const english = await this.loadLocale('en'); + + for (const languageCode of this.supportedLanguages) { + const locale = await this.loadLocale(languageCode); + const missingKeys = this.findMissingKeys(english, locale); + if (missingKeys.length > 0) { + throw new Error( + `Locale ${languageCode} is missing keys: ${missingKeys.join(', ')}`, + ); + } + } + } + + private async loadLocale(languageCode: string): Promise { + const filePath = join( + this.localesPath, + languageCode, + `${languageCode}.json`, + ); + const raw = await readFile(filePath, 'utf8'); + + try { + return JSON.parse(raw) as LocaleData; + } catch { + throw new Error(`Invalid JSON in locale file ${filePath}`); + } + } + + private findMissingKeys( + source: LocaleData, + target: LocaleData, + prefix = '', + ): string[] { + const missing: string[] = []; + + for (const [key, value] of Object.entries(source)) { + const currentKey = prefix ? `${prefix}.${key}` : key; + const targetValue = target[key]; + + if (targetValue === undefined) { + missing.push(currentKey); + continue; + } + + if (this.isPlainObject(value) && this.isPlainObject(targetValue)) { + missing.push(...this.findMissingKeys(value, targetValue, currentKey)); + } + } + + return missing; + } + + private isPlainObject(value: unknown): value is LocaleData { + return !!value && typeof value === 'object' && !Array.isArray(value); + } +} diff --git a/backend/src/module/user-profile/dto/update-language.dto.ts b/backend/src/module/user-profile/dto/update-language.dto.ts new file mode 100644 index 0000000..e2a2585 --- /dev/null +++ b/backend/src/module/user-profile/dto/update-language.dto.ts @@ -0,0 +1,6 @@ +import { IsIn } from 'class-validator'; + +export class UpdateLanguageDto { + @IsIn(['en', 'fr']) + languageCode: string; +} diff --git a/backend/src/module/user-profile/user-profile.controller.ts b/backend/src/module/user-profile/user-profile.controller.ts index 4f81da1..ec19dfa 100644 --- a/backend/src/module/user-profile/user-profile.controller.ts +++ b/backend/src/module/user-profile/user-profile.controller.ts @@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; import { User } from '../../users/entities/user.entity'; +import { UpdateLanguageDto } from './dto/update-language.dto'; @Controller('module/users') @UseGuards(JwtAuthGuard) @@ -14,8 +15,11 @@ export class UserProfileController { @Get('me') getProfile(@Req() req: { user: User }) { - const { passwordHash, twoFactorSecret, twoFactorBackupCodes, ...profile } = req.user; - void passwordHash; void twoFactorSecret; void twoFactorBackupCodes; + const { passwordHash, twoFactorSecret, twoFactorBackupCodes, ...profile } = + req.user; + void passwordHash; + void twoFactorSecret; + void twoFactorBackupCodes; return profile; } @@ -26,8 +30,29 @@ export class UserProfileController { ) { await this.users.update(req.user.id, body); const updated = await this.users.findOneByOrFail({ id: req.user.id }); - const { passwordHash, twoFactorSecret, twoFactorBackupCodes, ...profile } = updated; - void passwordHash; void twoFactorSecret; void twoFactorBackupCodes; + const { passwordHash, twoFactorSecret, twoFactorBackupCodes, ...profile } = + updated; + void passwordHash; + void twoFactorSecret; + void twoFactorBackupCodes; return profile; } -} \ No newline at end of file + + @Patch('me/language') + async updateLanguage( + @Req() req: { user: User }, + @Body() body: UpdateLanguageDto, + ) { + await this.users.update(req.user.id, { + preferredLanguage: body.languageCode, + }); + + const updated = await this.users.findOneByOrFail({ id: req.user.id }); + const { passwordHash, twoFactorSecret, twoFactorBackupCodes, ...profile } = + updated; + void passwordHash; + void twoFactorSecret; + void twoFactorBackupCodes; + return profile; + } +} diff --git a/backend/src/module/user-profile/user-profile.module.ts b/backend/src/module/user-profile/user-profile.module.ts new file mode 100644 index 0000000..263fd32 --- /dev/null +++ b/backend/src/module/user-profile/user-profile.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { UserProfileController } from './user-profile.controller'; +import { User } from '../../users/entities/user.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([User])], + controllers: [UserProfileController], +}) +export class UserProfileModule {}