diff --git a/package-lock.json b/package-lock.json index a531c6dc..7ac8dc7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "nestjs-i18n": "^10.6.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", @@ -1033,7 +1034,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", - "dev": true, "license": "MIT", "optional": true }, @@ -1992,6 +1992,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", @@ -2946,7 +2990,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -2959,7 +3002,6 @@ "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", "deprecated": "This functionality has been moved to @npmcli/fs", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -3167,7 +3209,7 @@ "version": "1.13.5", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.5.tgz", "integrity": "sha512-WezcBo8a0Dg2rnR82zhwoR6aRNxeTGfK5QCD6TQ+kg3xx/zNT02s/0o+81h/3zhvFSB24NtqEr8FTw88O5W/JQ==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -3209,7 +3251,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3226,7 +3267,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3243,7 +3283,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3260,7 +3299,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3277,7 +3315,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3294,7 +3331,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3311,7 +3347,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3328,7 +3363,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3345,7 +3379,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3362,7 +3395,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3376,14 +3408,14 @@ "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@swc/types": { "version": "0.1.25", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3" @@ -3399,7 +3431,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -4665,7 +4696,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "dev": true, "license": "ISC", "optional": true }, @@ -4725,7 +4755,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -4739,7 +4768,6 @@ "version": "4.6.0", "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -4753,7 +4781,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -4899,7 +4926,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", @@ -4913,7 +4939,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" @@ -4941,7 +4966,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", - "dev": true, "license": "ISC", "optional": true }, @@ -4971,7 +4995,6 @@ "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", "deprecated": "This package is no longer supported.", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -5213,6 +5236,18 @@ "node": ">= 18" } }, + "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/binary-version": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/binary-version/-/binary-version-7.1.0.tgz", @@ -5429,7 +5464,7 @@ "version": "1.1.13", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -5589,7 +5624,6 @@ "version": "15.3.0", "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -5621,7 +5655,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -5643,7 +5676,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -5657,7 +5689,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -5671,7 +5702,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, "license": "ISC", "optional": true }, @@ -5920,7 +5950,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -6081,7 +6110,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", - "dev": true, "license": "ISC", "optional": true, "bin": { @@ -6139,7 +6167,7 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/concat-stream": { @@ -6170,7 +6198,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", - "dev": true, "license": "ISC", "optional": true }, @@ -6436,7 +6463,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", - "dev": true, "license": "MIT", "optional": true }, @@ -6615,7 +6641,6 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -6626,7 +6651,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -6664,7 +6688,6 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -6687,7 +6710,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "dev": true, "license": "MIT", "optional": true }, @@ -7004,18 +7026,6 @@ "node": ">=8.6.0" } }, - "node_modules/fast-glob/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/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -7483,14 +7493,13 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/fsevents": { "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, @@ -7528,7 +7537,6 @@ "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", "deprecated": "This package is no longer supported.", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -7549,7 +7557,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -7560,7 +7567,6 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, "license": "ISC", "optional": true }, @@ -7568,7 +7574,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -7697,6 +7702,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "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/glob-to-regexp": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", @@ -7813,7 +7830,7 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/handlebars": { @@ -7901,7 +7918,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", - "dev": true, "license": "ISC", "optional": true }, @@ -7928,7 +7944,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause" }, "node_modules/http-errors": { @@ -7955,7 +7971,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -7985,7 +8000,6 @@ "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==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -8010,7 +8024,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -8094,7 +8107,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.8.19" @@ -8104,7 +8117,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -8115,7 +8127,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", - "dev": true, "license": "ISC", "optional": true }, @@ -8124,7 +8135,7 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -8158,7 +8169,6 @@ "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -8181,6 +8191,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", @@ -8247,7 +8269,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", - "dev": true, "license": "MIT", "optional": true }, @@ -9421,7 +9442,6 @@ "version": "9.1.0", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -9450,7 +9470,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -9464,7 +9483,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -9478,7 +9496,6 @@ "version": "0.6.4", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -9489,10 +9506,15 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, "license": "ISC", "optional": true }, + "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", @@ -9662,7 +9684,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -9694,7 +9716,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -9708,7 +9729,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -9722,7 +9742,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, "license": "ISC", "optional": true }, @@ -9730,7 +9749,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -9749,7 +9767,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -9763,7 +9780,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, "license": "ISC", "optional": true }, @@ -9771,7 +9787,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", - "dev": true, "license": "BlueOak-1.0.0", "optional": true, "dependencies": { @@ -9785,7 +9800,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -9799,7 +9813,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, "license": "ISC", "optional": true }, @@ -9807,7 +9820,6 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -9821,7 +9833,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -9835,7 +9846,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, "license": "ISC", "optional": true }, @@ -9843,7 +9853,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -9857,7 +9866,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -9871,7 +9879,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, "license": "ISC", "optional": true }, @@ -9929,6 +9936,12 @@ "devOptional": true, "license": "MIT" }, + "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", @@ -10053,6 +10066,97 @@ "dev": true, "license": "MIT" }, + "node_modules/nestjs-i18n": { + "version": "10.8.3", + "resolved": "https://registry.npmjs.org/nestjs-i18n/-/nestjs-i18n-10.8.3.tgz", + "integrity": "sha512-Qizgu6wjnrZ+l/A4qqoMhRWswLqImLt0adkmmi5LOi7Xg8+zFjzwvLavIFsWERX8dqCtoNoKcn7hxsmKqMMBnA==", + "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/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/no-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", @@ -10106,7 +10210,6 @@ "version": "8.4.1", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -10144,7 +10247,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -10180,7 +10282,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -10197,7 +10298,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -10234,7 +10334,6 @@ "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", "deprecated": "This package is no longer supported.", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -10398,7 +10497,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -10565,7 +10663,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -10978,7 +11076,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", - "dev": true, "license": "ISC", "optional": true }, @@ -10986,7 +11083,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -11292,7 +11388,6 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -11314,7 +11409,6 @@ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -11332,7 +11426,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -11418,6 +11511,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/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -11557,7 +11656,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "dev": true, "license": "ISC", "optional": true }, @@ -11770,7 +11868,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -11788,7 +11885,6 @@ "version": "2.8.7", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -11804,7 +11900,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -11941,7 +12036,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -11955,7 +12049,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -11969,7 +12062,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, "license": "ISC", "optional": true }, @@ -12040,6 +12132,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", @@ -13363,7 +13461,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -13374,7 +13471,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -13816,7 +13912,6 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { diff --git a/package.json b/package.json index 46e9b4aa..e61f7cf3 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "nestjs-i18n": "^10.6.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", diff --git a/src/app.module.ts b/src/app.module.ts index e99ac29d..98ba4816 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,11 +1,14 @@ import { Module } from "@nestjs/common"; import { ConfigModule } from "@nestjs/config"; +import { APP_INTERCEPTOR } from "@nestjs/core"; import { AppController } from "./app.controller"; import { AuthModule } from "./auth/auth.module"; +import { TranslationInterceptor } from "./common/interceptors/translation.interceptor"; import { CoursesModule } from "./courses/courses.module"; import { DatabaseModule } from "./database/database.module"; import { EventsModule } from "./events/events.module"; import { FacultiesModule } from "./faculties/faculties.module"; +import { I18nModule } from "./i18n/i18n.module"; import { ServicesModule } from "./services/services.module"; import { UsersModule } from "./users/users.module"; @@ -15,6 +18,7 @@ import { UsersModule } from "./users/users.module"; envFilePath: [".env.local"], }), DatabaseModule, + I18nModule, FacultiesModule, UsersModule, AuthModule, @@ -23,5 +27,11 @@ import { UsersModule } from "./users/users.module"; CoursesModule, ], controllers: [AppController], + providers: [ + { + provide: APP_INTERCEPTOR, + useClass: TranslationInterceptor, + }, + ], }) export class AppModule {} diff --git a/src/common/dto.spec.ts b/src/common/dto.spec.ts new file mode 100644 index 00000000..17cf8302 --- /dev/null +++ b/src/common/dto.spec.ts @@ -0,0 +1,84 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { plainToInstance } from "class-transformer"; +import { validate } from "class-validator"; + +describe("DTO Tests", () => { + let module: TestingModule; + + beforeAll(async () => { + module = await Test.createTestingModule({}).compile(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe("CourseTranslationDto", () => { + it("should validate with correct data", async () => { + const { CourseTranslationDto } = await import( + "@/courses/dto/create-course.dto" + ); + const dto = plainToInstance(CourseTranslationDto, { + languageCode: "en", + name: "Computer Science", + acronym: "CS", + }); + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + }); + + describe("CreateCourseDto", () => { + it("should be defined", async () => { + const { CreateCourseDto } = await import( + "@/courses/dto/create-course.dto" + ); + expect(new CreateCourseDto()).toBeDefined(); + }); + }); + + describe("EventTranslationDto", () => { + it("should validate with correct data", async () => { + const { EventTranslationDto } = await import( + "@/events/dto/create-event.dto" + ); + const dto = plainToInstance(EventTranslationDto, { + languageCode: "en", + name: "FEUP Week", + }); + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + }); + + describe("CreateEventDto", () => { + it("should be defined", async () => { + const { CreateEventDto } = await import("@/events/dto/create-event.dto"); + expect(new CreateEventDto()).toBeDefined(); + }); + }); + + describe("FacultyTranslationDto", () => { + it("should validate with correct data", async () => { + const { FacultyTranslationDto } = await import( + "@/faculties/dto/create-faculty.dto" + ); + const dto = plainToInstance(FacultyTranslationDto, { + languageCode: "en", + name: "Faculty of Engineering", + acronym: "FEUP", + }); + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + }); + + describe("CreateFacultyDto", () => { + it("should be defined", async () => { + const { CreateFacultyDto } = await import( + "@/faculties/dto/create-faculty.dto" + ); + expect(new CreateFacultyDto()).toBeDefined(); + }); + }); +}); diff --git a/src/common/interceptors/translation.interceptor.spec.ts b/src/common/interceptors/translation.interceptor.spec.ts new file mode 100644 index 00000000..be58b713 --- /dev/null +++ b/src/common/interceptors/translation.interceptor.spec.ts @@ -0,0 +1,215 @@ +import { ExecutionContext } from "@nestjs/common"; +import { of } from "rxjs"; +import { I18nService } from "@/i18n/i18n.service"; +import { TranslationInterceptor } from "./translation.interceptor"; + +const createMockRequest = (headers: Record = {}) => ({ + headers, + i18nLanguage: undefined as string | undefined, +}); + +describe("TranslationInterceptor", () => { + let interceptor: TranslationInterceptor; + let i18nService: I18nService; + let mockContext: ExecutionContext; + let mockRequest: { headers: Record; i18nLanguage?: string }; + + beforeEach(() => { + i18nService = new I18nService(); + interceptor = new TranslationInterceptor(i18nService); + + mockRequest = createMockRequest(); + mockContext = { + switchToHttp: () => ({ + getRequest: () => mockRequest, + }), + } as ExecutionContext; + }); + + describe("extractLanguage", () => { + it("should return en for undefined", () => { + const result = interceptor.intercept(mockContext, { + handle: () => of({}), + } as any); + expect(result).toBeDefined(); + }); + + it("should return en when accept-language is not provided", () => { + mockRequest = createMockRequest({}); + interceptor.intercept(mockContext, { + handle: () => of({}), + } as any); + expect(mockRequest.i18nLanguage).toBe("en"); + }); + + it("should return en when accept-language is empty", () => { + mockRequest = createMockRequest({ "accept-language": "" }); + interceptor.intercept(mockContext, { + handle: () => of({}), + } as any); + expect(mockRequest.i18nLanguage).toBe("en"); + }); + + it("should return pt when accept-language is pt", () => { + mockRequest = createMockRequest({ "accept-language": "pt" }); + interceptor.intercept(mockContext, { + handle: () => of({}), + } as any); + expect(mockRequest.i18nLanguage).toBe("pt"); + }); + + it("should return pt when accept-language has pt-BR", () => { + mockRequest = createMockRequest({ "accept-language": "pt-BR" }); + interceptor.intercept(mockContext, { + handle: () => of({}), + } as any); + expect(mockRequest.i18nLanguage).toBe("pt"); + }); + + it("should return en for unsupported language", () => { + mockRequest = createMockRequest({ "accept-language": "fr" }); + interceptor.intercept(mockContext, { + handle: () => of({}), + } as any); + expect(mockRequest.i18nLanguage).toBe("en"); + }); + + it("should handle accept-language with quality value", () => { + mockRequest = createMockRequest({ + "accept-language": "pt;q=0.9, en;q=0.8", + }); + interceptor.intercept(mockContext, { + handle: () => of({}), + } as any); + expect(mockRequest.i18nLanguage).toBe("pt"); + }); + + it("should process response through pipe", async () => { + const mockData = { + translations: [{ languageCode: "en", name: "Test" }], + }; + + let result: any; + interceptor + .intercept(mockContext, { + handle: () => of(mockData), + } as any) + .subscribe((data) => { + result = data; + }); + + expect(result).toBeDefined(); + expect(result.name).toBe("Test"); + }); + }); + + describe("transformResponse", () => { + it("should return data as is if null", () => { + const result = (interceptor as any).transformResponse(null, "en"); + expect(result).toBeNull(); + }); + + it("should return data as is if not object", () => { + const result = (interceptor as any).transformResponse("string", "en"); + expect(result).toBe("string"); + }); + + it("should transform array of items", () => { + const data = [ + { + translations: [{ languageCode: "en", name: "Test", acronym: "T" }], + }, + ]; + const result = (interceptor as any).transformResponse(data, "en"); + expect(result[0].name).toBe("Test"); + expect(result[0].translations).toBeUndefined(); + }); + + it("should transform single item", () => { + const data = { + translations: [{ languageCode: "en", name: "Test", acronym: "T" }], + }; + const result = (interceptor as any).transformResponse(data, "en"); + expect(result.name).toBe("Test"); + expect(result.translations).toBeUndefined(); + }); + + it("should fallback to en if requested language not available", () => { + const data = { + translations: [{ languageCode: "pt", name: "Teste" }], + }; + const result = (interceptor as any).transformResponse(data, "pt"); + expect(result.name).toBe("Teste"); + }); + + it("should return original item if no translations", () => { + const data = { id: 1, name: "Test" }; + const result = (interceptor as any).transformResponse(data, "en"); + expect(result).toEqual(data); + }); + + it("should return item with translations when no matching language and no en fallback", () => { + const data = { + translations: [{ languageCode: "es", name: "Prueba" }], + }; + const result = (interceptor as any).transformResponse(data, "pt"); + expect(result.translations).toBeDefined(); + }); + + it("should include description if available in translation", () => { + const data = { + translations: [ + { + languageCode: "en", + name: "Test", + description: "Test description", + }, + ], + }; + const result = (interceptor as any).transformResponse(data, "en"); + expect(result.name).toBe("Test"); + expect(result.description).toBe("Test description"); + }); + + it("should delete defaultLanguage from result", () => { + const data = { + defaultLanguage: "en", + translations: [{ languageCode: "en", name: "Test" }], + }; + const result = (interceptor as any).transformResponse(data, "en"); + expect(result.defaultLanguage).toBeUndefined(); + }); + + it("should apply all translation fields when available", () => { + const data = { + translations: [ + { + languageCode: "en", + name: "Test Name", + acronym: "TNA", + description: "Test Desc", + }, + ], + }; + const result = (interceptor as any).transformResponse(data, "en"); + expect(result.name).toBe("Test Name"); + expect(result.acronym).toBe("TNA"); + expect(result.description).toBe("Test Desc"); + }); + + it("should handle translation without optional name", () => { + const data = { + translations: [ + { + languageCode: "en", + acronym: "TNA", + description: "Test Desc", + }, + ], + }; + const result = (interceptor as any).transformResponse(data, "en"); + expect(result.acronym).toBe("TNA"); + expect(result.name).toBeUndefined(); + }); + }); +}); diff --git a/src/common/interceptors/translation.interceptor.ts b/src/common/interceptors/translation.interceptor.ts new file mode 100644 index 00000000..b99cf68f --- /dev/null +++ b/src/common/interceptors/translation.interceptor.ts @@ -0,0 +1,82 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from "@nestjs/common"; +import { Observable } from "rxjs"; +import { map } from "rxjs/operators"; +import { I18nService } from "@/i18n/i18n.service"; + +function extractLanguage(acceptLanguage: string | undefined): string { + if (!acceptLanguage) return "en"; + + const lang = acceptLanguage.split(",")[0].split(";")[0].trim().toLowerCase(); + + const supported = ["en", "pt"]; + if (supported.includes(lang)) return lang; + + const prefix = lang.split("-")[0]; + if (supported.includes(prefix)) return prefix; + + return "en"; +} + +@Injectable() +export class TranslationInterceptor implements NestInterceptor { + constructor(private i18nService: I18nService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + const language = extractLanguage(request.headers["accept-language"]); + + this.i18nService.setLanguage(language); + request.i18nLanguage = language; + + return next + .handle() + .pipe(map((data) => this.transformResponse(data, language))); + } + + private transformResponse(data: unknown, language: string): unknown { + if (!data) return data; + + if (Array.isArray(data)) { + return data.map((item) => + this.transformItem(item as Record, language), + ); + } + + if (typeof data === "object" && data !== null) { + return this.transformItem(data as Record, language); + } + + return data; + } + + private transformItem( + item: Record, + language: string, + ): Record { + if (!item.translations) return item; + + const translations = item.translations as Array>; + + const translation = + translations.find((t) => (t.languageCode as string) === language) || + translations.find((t) => (t.languageCode as string) === "en"); + + if (!translation) return item; + + const result: Record = { ...item }; + delete result.translations; + delete result.defaultLanguage; + + if (translation.name !== undefined) result.name = translation.name; + if (translation.acronym !== undefined) result.acronym = translation.acronym; + if (translation.description !== undefined) + result.description = translation.description; + + return result; + } +} diff --git a/src/courses/courses.controller.spec.ts b/src/courses/courses.controller.spec.ts index e4c9f389..b40911df 100644 --- a/src/courses/courses.controller.spec.ts +++ b/src/courses/courses.controller.spec.ts @@ -11,8 +11,17 @@ describe("CoursesController", () => { const mockCourse: Course = { id: 1, - name: "Computer Science", - acronym: "CS", + defaultLanguage: "en", + translations: [ + { + id: 1, + courseId: 1, + languageCode: "en", + name: "Computer Science", + acronym: "CS", + course: undefined as any, + }, + ], faculties: [], events: [], }; @@ -80,8 +89,9 @@ describe("CoursesController", () => { describe("create", () => { it("should create a new course", async () => { const createCourseDto: CreateCourseDto = { - name: "Computer Science", - acronym: "CS", + translations: [ + { languageCode: "en", name: "Computer Science", acronym: "CS" }, + ], facultyIds: [1, 2], }; mockCoursesService.create.mockResolvedValue(mockCourse); @@ -96,9 +106,11 @@ describe("CoursesController", () => { describe("update", () => { it("should update a course", async () => { const updateCourseDto: UpdateCourseDto = { - name: "Updated Course Name", + translations: [ + { languageCode: "en", name: "Updated Course Name", acronym: "CS" }, + ], }; - const updatedCourse = { ...mockCourse, ...updateCourseDto }; + const updatedCourse = { ...mockCourse }; mockCoursesService.update.mockResolvedValue(updatedCourse); const result = await controller.update(1, updateCourseDto); @@ -112,7 +124,9 @@ describe("CoursesController", () => { it("should throw NotFoundException if course not found", async () => { const updateCourseDto: UpdateCourseDto = { - name: "Updated Course Name", + translations: [ + { languageCode: "en", name: "Updated Course Name", acronym: "CS" }, + ], }; mockCoursesService.update.mockRejectedValue(new NotFoundException()); diff --git a/src/courses/courses.module.spec.ts b/src/courses/courses.module.spec.ts index 2ed87ce0..297aba74 100644 --- a/src/courses/courses.module.spec.ts +++ b/src/courses/courses.module.spec.ts @@ -1,11 +1,16 @@ import { Test, TestingModule } from "@nestjs/testing"; import { TypeOrmModule } from "@nestjs/typeorm"; +import { Course } from "@/courses/entities/course.entity"; import { Event } from "@/events/entities/event.entity"; import { Faculty } from "@/faculties/entities/faculty.entity"; +import { + CourseTranslation, + EventTranslation, + FacultyTranslation, +} from "@/i18n/entities"; import { CoursesController } from "./courses.controller"; import { CoursesModule } from "./courses.module"; import { CoursesService } from "./courses.service"; -import { Course } from "./entities/course.entity"; describe("CoursesModule", () => { let module: TestingModule; @@ -16,7 +21,14 @@ describe("CoursesModule", () => { TypeOrmModule.forRoot({ type: "sqlite", database: ":memory:", - entities: [Course, Faculty, Event], + entities: [ + Course, + CourseTranslation, + Event, + EventTranslation, + Faculty, + FacultyTranslation, + ], synchronize: true, }), CoursesModule, diff --git a/src/courses/courses.module.ts b/src/courses/courses.module.ts index 417ae641..c64789d1 100644 --- a/src/courses/courses.module.ts +++ b/src/courses/courses.module.ts @@ -1,12 +1,13 @@ import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; import { Faculty } from "@/faculties/entities/faculty.entity"; +import { CourseTranslation } from "@/i18n/entities"; import { CoursesController } from "./courses.controller"; import { CoursesService } from "./courses.service"; import { Course } from "./entities/course.entity"; @Module({ - imports: [TypeOrmModule.forFeature([Course, Faculty])], + imports: [TypeOrmModule.forFeature([Course, Faculty, CourseTranslation])], controllers: [CoursesController], providers: [CoursesService], }) diff --git a/src/courses/courses.service.spec.ts b/src/courses/courses.service.spec.ts index c200dcfa..0347261c 100644 --- a/src/courses/courses.service.spec.ts +++ b/src/courses/courses.service.spec.ts @@ -12,16 +12,34 @@ describe("CoursesService", () => { const mockCourse: Course = { id: 1, - name: "Computer Science", - acronym: "CS", + defaultLanguage: "en", + translations: [ + { + id: 1, + courseId: 1, + languageCode: "en", + name: "Computer Science", + acronym: "CS", + course: undefined as any, + }, + ], faculties: [], events: [], }; const mockFaculty: Faculty = { id: 1, - name: "Engineering Faculty", - acronym: "FEUP", + defaultLanguage: "en", + translations: [ + { + id: 1, + facultyId: 1, + languageCode: "en", + name: "Engineering Faculty", + acronym: "FEUP", + faculty: undefined as any, + }, + ], courses: [], events: [], }; @@ -76,7 +94,7 @@ describe("CoursesService", () => { expect(result).toEqual(courses); expect(mockCourseRepository.find).toHaveBeenCalledWith({ - relations: ["faculties"], + relations: ["translations", "faculties"], }); }); }); @@ -90,7 +108,7 @@ describe("CoursesService", () => { expect(result).toEqual(mockCourse); expect(mockCourseRepository.findOneOrFail).toHaveBeenCalledWith({ where: { id: 1 }, - relations: ["faculties"], + relations: ["translations", "faculties"], }); }); @@ -106,23 +124,45 @@ describe("CoursesService", () => { describe("create", () => { it("should create a course without faculties", async () => { const createCourseDto: CreateCourseDto = { - name: "Computer Science", - acronym: "CS", + translations: [ + { languageCode: "en", name: "Computer Science", acronym: "CS" }, + ], + }; + mockCourseRepository.save.mockResolvedValue(mockCourse); + + const result = await service.create(createCourseDto); + + expect(result).toEqual(mockCourse); + expect(mockCourseRepository.save).toHaveBeenCalled(); + }); + + it("should create a course without translations", async () => { + const createCourseDto: CreateCourseDto = {} as CreateCourseDto; + mockCourseRepository.save.mockResolvedValue(mockCourse); + + const result = await service.create(createCourseDto); + + expect(result).toEqual(mockCourse); + expect(mockCourseRepository.save).toHaveBeenCalled(); + }); + + it("should create a course with empty translations array", async () => { + const createCourseDto: CreateCourseDto = { + translations: [], }; - mockCourseRepository.create.mockReturnValue(mockCourse); mockCourseRepository.save.mockResolvedValue(mockCourse); const result = await service.create(createCourseDto); expect(result).toEqual(mockCourse); - expect(mockCourseRepository.create).toHaveBeenCalledWith(createCourseDto); - expect(mockCourseRepository.save).toHaveBeenCalledWith(mockCourse); + expect(mockCourseRepository.save).toHaveBeenCalled(); }); it("should create a course with valid faculties", async () => { const createCourseDto: CreateCourseDto = { - name: "Computer Science", - acronym: "CS", + translations: [ + { languageCode: "en", name: "Computer Science", acronym: "CS" }, + ], facultyIds: [1], }; mockCourseRepository.create.mockReturnValue({ ...mockCourse }); @@ -140,8 +180,9 @@ describe("CoursesService", () => { it("should throw NotFoundException if any faculty is not found", async () => { const createCourseDto: CreateCourseDto = { - name: "Computer Science", - acronym: "CS", + translations: [ + { languageCode: "en", name: "Computer Science", acronym: "CS" }, + ], facultyIds: [1, 2], }; mockCourseRepository.create.mockReturnValue(mockCourse); @@ -155,17 +196,15 @@ describe("CoursesService", () => { describe("update", () => { it("should update a course successfully", async () => { - const updateCourseDto: UpdateCourseDto = { name: "New Name" }; + const updateCourseDto: UpdateCourseDto = { + translations: [{ languageCode: "en", name: "New Name", acronym: "CS" }], + }; mockCourseRepository.findOneByOrFail.mockResolvedValue({ ...mockCourse }); - mockCourseRepository.save.mockResolvedValue({ - ...mockCourse, - name: "New Name", - }); + mockCourseRepository.save.mockResolvedValue({ ...mockCourse }); const result = await service.update(1, updateCourseDto); - expect(result.name).toEqual("New Name"); - expect(mockCourseRepository.merge).toHaveBeenCalled(); + expect(result).toBeDefined(); expect(mockCourseRepository.save).toHaveBeenCalled(); }); diff --git a/src/courses/courses.service.ts b/src/courses/courses.service.ts index 148d0fe4..3c6720ad 100644 --- a/src/courses/courses.service.ts +++ b/src/courses/courses.service.ts @@ -3,6 +3,7 @@ import { InjectRepository } from "@nestjs/typeorm"; import { Repository } from "typeorm"; import { validateAndGetRelations } from "@/common/utils/entity-relation.utils"; import { Faculty } from "@/faculties/entities/faculty.entity"; +import { CourseTranslation } from "@/i18n/entities"; import { CreateCourseDto } from "./dto/create-course.dto"; import { UpdateCourseDto } from "./dto/update-course.dto"; import { Course } from "./entities/course.entity"; @@ -17,8 +18,19 @@ export class CoursesService { ) {} async create(createCourseDto: CreateCourseDto): Promise { - const { facultyIds, ...courseData } = createCourseDto; - const course = this.courseRepository.create(courseData); + const { facultyIds, translations } = createCourseDto; + const course = new Course(); + course.defaultLanguage = "en"; + + if (translations && translations.length > 0) { + course.translations = translations.map((t) => { + const translation = new CourseTranslation(); + translation.languageCode = t.languageCode; + translation.name = t.name; + translation.acronym = t.acronym; + return translation; + }); + } if (facultyIds !== undefined) { course.faculties = await validateAndGetRelations( @@ -32,23 +44,23 @@ export class CoursesService { } findAll(): Promise { - return this.courseRepository.find({ relations: ["faculties"] }); + return this.courseRepository.find({ + relations: ["translations", "faculties"], + }); } findOne(id: number): Promise { return this.courseRepository.findOneOrFail({ where: { id }, - relations: ["faculties"], + relations: ["translations", "faculties"], }); } async update(id: number, updateCourseDto: UpdateCourseDto): Promise { - const { facultyIds, ...courseData } = updateCourseDto; + const { facultyIds } = updateCourseDto; const course = await this.courseRepository.findOneByOrFail({ id }); - this.courseRepository.merge(course, courseData); - if (facultyIds !== undefined) { course.faculties = await validateAndGetRelations( this.facultyRepository, diff --git a/src/courses/dto/create-course.dto.spec.ts b/src/courses/dto/create-course.dto.spec.ts new file mode 100644 index 00000000..dd799191 --- /dev/null +++ b/src/courses/dto/create-course.dto.spec.ts @@ -0,0 +1,54 @@ +import "reflect-metadata"; +import { plainToInstance } from "class-transformer"; +import { validate } from "class-validator"; +import { CourseTranslationDto, CreateCourseDto } from "./create-course.dto"; + +describe("CreateCourseDto", () => { + it("should be defined", () => { + expect(CreateCourseDto).toBeDefined(); + }); + + it("should have translations property", () => { + const dto = new CreateCourseDto(); + expect(dto.translations).toBeUndefined(); + }); + + it("should validate translations using class-transformer", () => { + const plain = { + translations: [{ languageCode: "en", name: "Test", acronym: "T" }], + }; + const dto = plainToInstance(CreateCourseDto, plain); + expect(dto.translations).toHaveLength(1); + expect(dto.translations[0].languageCode).toBe("en"); + }); + + it("should allow setting facultyIds", () => { + const dto = new CreateCourseDto(); + dto.facultyIds = [1, 2]; + expect(dto.facultyIds).toEqual([1, 2]); + }); + + it("should validate valid DTO", async () => { + const dto = plainToInstance(CreateCourseDto, { + translations: [{ languageCode: "en", name: "Test", acronym: "T" }], + }); + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); +}); + +describe("CourseTranslationDto", () => { + it("should be defined", () => { + expect(CourseTranslationDto).toBeDefined(); + }); + + it("should have languageCode, name, acronym properties", () => { + const dto = new CourseTranslationDto(); + dto.languageCode = "en"; + dto.name = "Course"; + dto.acronym = "C"; + expect(dto.languageCode).toBe("en"); + expect(dto.name).toBe("Course"); + expect(dto.acronym).toBe("C"); + }); +}); diff --git a/src/courses/dto/create-course.dto.ts b/src/courses/dto/create-course.dto.ts index 76ac7582..318f56fc 100644 --- a/src/courses/dto/create-course.dto.ts +++ b/src/courses/dto/create-course.dto.ts @@ -1,32 +1,29 @@ +import { Type } from "class-transformer"; import { IsArray, - IsNotEmpty, IsNumber, IsOptional, IsString, + ValidateNested, } from "class-validator"; -export class CreateCourseDto { - /** - * The name of the course. - * @example 'Bachelor in Informatics and Computing Engineering' - */ +export class CourseTranslationDto { + @IsString() + languageCode: string; + @IsString() - @IsNotEmpty() name: string; - /** - * The acronym of the course. - * @example 'LEIC' - */ @IsString() - @IsNotEmpty() acronym: string; +} + +export class CreateCourseDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CourseTranslationDto) + translations: CourseTranslationDto[]; - /** - * The IDs of the faculties associated with the course. - * @example [1, 2] - */ @IsArray() @IsNumber({}, { each: true }) @IsOptional() diff --git a/src/courses/entities/course.entity.ts b/src/courses/entities/course.entity.ts index ef4a677d..a1f9d8ad 100644 --- a/src/courses/entities/course.entity.ts +++ b/src/courses/entities/course.entity.ts @@ -1,6 +1,13 @@ -import { Column, Entity, ManyToMany, PrimaryGeneratedColumn } from "typeorm"; +import { + Column, + Entity, + ManyToMany, + OneToMany, + PrimaryGeneratedColumn, +} from "typeorm"; import { Event } from "@/events/entities/event.entity"; import { Faculty } from "@/faculties/entities/faculty.entity"; +import { CourseTranslation } from "@/i18n/entities"; @Entity() export class Course { @@ -12,22 +19,24 @@ export class Course { id: number; /** - * The name of the course. - * @example 'Bachelor in Informatics and Computing Engineering' + * The default language for this course's translations. + * @example "en" */ - @Column() - name: string; + @Column({ name: "default_language", length: 10, default: "en" }) + defaultLanguage: string; /** - * The acronym of the course. - * @example 'LEIC' + * Translations for the course name and acronym. */ - @Column() - acronym: string; + @OneToMany( + () => CourseTranslation, + (translation) => translation.course, + { cascade: true }, + ) + translations: CourseTranslation[]; /** - * The faculties associated with the course. - * @example [{ id: 1, name: 'Faculty of Engineering', acronym: 'FEUP' }] + * The faculties associated with this course. */ @ManyToMany( () => Faculty, diff --git a/src/database/factories/course.factory.spec.ts b/src/database/factories/course.factory.spec.ts index 36389e8f..d0acd3b6 100644 --- a/src/database/factories/course.factory.spec.ts +++ b/src/database/factories/course.factory.spec.ts @@ -28,8 +28,10 @@ describe("CourseFactory", () => { const course = factory.factoryFn(); expect(course).toBeInstanceOf(Course); - expect(course.name).toBeTruthy(); - expect(course.acronym).toBeTruthy(); - expect(course.acronym.length).toBeGreaterThanOrEqual(2); + expect(course.translations).toBeDefined(); + expect(course.translations.length).toBeGreaterThan(0); + expect(course.translations[0].name).toBeTruthy(); + expect(course.translations[0].acronym).toBeTruthy(); + expect(course.translations[0].acronym.length).toBeGreaterThanOrEqual(2); }); }); diff --git a/src/database/factories/course.factory.ts b/src/database/factories/course.factory.ts index f784a72d..e387bb6d 100644 --- a/src/database/factories/course.factory.ts +++ b/src/database/factories/course.factory.ts @@ -1,30 +1,60 @@ import { faker } from "@faker-js/faker"; import { setSeederFactory } from "typeorm-extension"; import { Course } from "@/courses/entities/course.entity"; +import { CourseTranslation } from "@/i18n/entities"; const degreeTypes = ["Bachelor", "Master", "PhD"]; -const fields = [ - "Informatics Engineering", - "Civil Engineering", - "Mechanical Engineering", - "Electrical Engineering", - "Chemical Engineering", - "Bioengineering", - "Environmental Engineering", - "Industrial Engineering", - "Data Science", - "Artificial Intelligence", -]; +const fields: Record = { + "Informatics Engineering": { + en: "Informatics Engineering", + pt: "Engenharia Informática", + }, + "Civil Engineering": { en: "Civil Engineering", pt: "Engenharia Civil" }, + "Mechanical Engineering": { + en: "Mechanical Engineering", + pt: "Engenharia Mecânica", + }, + "Electrical Engineering": { + en: "Electrical Engineering", + pt: "Engenharia Eletrotécnica", + }, + "Chemical Engineering": { + en: "Chemical Engineering", + pt: "Engenharia Química", + }, + Bioengineering: { en: "Bioengineering", pt: "Bioengenharia" }, + "Environmental Engineering": { + en: "Environmental Engineering", + pt: "Engenharia do Ambiente", + }, + "Industrial Engineering": { + en: "Industrial Engineering", + pt: "Engenharia Industrial", + }, + "Data Science": { en: "Data Science", pt: "Ciência de Dados" }, + "Artificial Intelligence": { + en: "Artificial Intelligence", + pt: "Inteligência Artificial", + }, +}; + +const degreeNames: Record = { + Bachelor: { en: "Bachelor", pt: "Licenciatura" }, + Master: { en: "Master", pt: "Mestrado" }, + PhD: { en: "PhD", pt: "Doutoramento" }, +}; export default setSeederFactory(Course, () => { const type = faker.helpers.arrayElement(degreeTypes); - const field = faker.helpers.arrayElement(fields); + const fieldKey = faker.helpers.arrayElement(Object.keys(fields)); + const field = fields[fieldKey]; - const name = `${type} in ${field}`; + const name = `${type} in ${field.en}`; + const namePt = `${degreeNames[type].pt} em ${field.pt}`; const typeAcronym = type[0]; - const fieldAcronym = field + const fieldAcronym = fieldKey .split(" ") .map((word) => word[0]) .join(""); @@ -32,8 +62,19 @@ export default setSeederFactory(Course, () => { const acronym = `${typeAcronym}${fieldAcronym}`.toUpperCase(); const course = new Course(); - course.name = name; - course.acronym = acronym; + course.defaultLanguage = "en"; + + const enTranslation = new CourseTranslation(); + enTranslation.languageCode = "en"; + enTranslation.name = name; + enTranslation.acronym = acronym; + + const ptTranslation = new CourseTranslation(); + ptTranslation.languageCode = "pt"; + ptTranslation.name = namePt; + ptTranslation.acronym = acronym; + + course.translations = [enTranslation, ptTranslation]; return course; }); diff --git a/src/database/factories/event.factory.spec.ts b/src/database/factories/event.factory.spec.ts index fcb4f707..84f7e085 100644 --- a/src/database/factories/event.factory.spec.ts +++ b/src/database/factories/event.factory.spec.ts @@ -29,8 +29,10 @@ describe("EventFactory", () => { const event = factory.factoryFn(); expect(event).toBeInstanceOf(Event); - expect(event.name).toBeTruthy(); - expect(event.description).toBeTruthy(); + expect(event.translations).toBeDefined(); + expect(event.translations.length).toBeGreaterThan(0); + expect(event.translations[0].name).toBeTruthy(); + expect(event.translations[0].description).toBeTruthy(); if (event.startDate) { expect(event.startDate).toBeInstanceOf(Date); diff --git a/src/database/factories/event.factory.ts b/src/database/factories/event.factory.ts index 6344b1c4..67e8897d 100644 --- a/src/database/factories/event.factory.ts +++ b/src/database/factories/event.factory.ts @@ -1,207 +1,315 @@ import { faker } from "@faker-js/faker"; import { setSeederFactory } from "typeorm-extension"; import { Event } from "@/events/entities/event.entity"; +import { EventTranslation } from "@/i18n/entities"; const eventTemplates = [ { - name: "Start of 1st Year/Master classes", - description: "Start of classes for 1st year and Master students.", + name: { + en: "Start of 1st Year/Master classes", + pt: "Início das aulas 1º ano/Mestrado", + }, + description: { + en: "Start of classes for 1st year and Master students.", + pt: "Início das aulas para estudantes do 1º ano e Mestrado.", + }, startDate: "2025-09-22T09:00:00Z", endDate: null, year: null, }, { - name: "Start of classes, others", - description: "Start of classes for all other students.", + name: { en: "Start of classes, others", pt: "Início das aulas, restantes" }, + description: { + en: "Start of classes for all other students.", + pt: "Início das aulas para todos os restantes estudantes.", + }, startDate: "2025-09-15T09:00:00Z", endDate: null, year: null, }, { - name: "FEUP Project Week (1st year/Bachelor)", - description: - "A week dedicated to FEUP projects for 1st year Bachelor students.", + name: { + en: "FEUP Project Week (1st year/Bachelor)", + pt: "Semana de Projetos FEUP (1º ano/Licenciatura)", + }, + description: { + en: "A week dedicated to FEUP projects for 1st year Bachelor students.", + pt: "Semana dedicada aos projetos FEUP para estudantes de 1º ano de Licenciatura.", + }, startDate: "2025-09-15T09:00:00Z", endDate: "2025-09-19T18:00:00Z", year: null, }, { - name: "New Masters Celebration", - description: "Celebration event for new Masters graduates.", + name: { en: "New Masters Celebration", pt: "Celebração Novos Mestres" }, + description: { + en: "Celebration event for new Masters graduates.", + pt: "Evento de celebração para novos Mestres.", + }, startDate: "2025-11-22T17:00:00Z", endDate: null, year: null, }, { - name: "FEUP Day", - description: "Annual FEUP Day celebration. Date to be defined.", + name: { en: "FEUP Day", pt: "Dia da FEUP" }, + description: { + en: "Annual FEUP Day celebration. Date to be defined.", + pt: "Celebração anual do Dia da FEUP. Data a definir.", + }, startDate: null, endDate: null, year: null, }, { - name: "FEUP and Staff Mobil. Week", - description: "Mobility week for FEUP staff and students.", + name: { + en: "FEUP and Staff Mobil. Week", + pt: "Semana de Mobilidade FEUP e Pessoal", + }, + description: { + en: "Mobility week for FEUP staff and students.", + pt: "Semana de mobilidade para pessoal e estudantes FEUP.", + }, startDate: "2025-10-27T09:00:00Z", endDate: "2025-10-31T18:00:00Z", year: null, }, { - name: "Christmas Holidays", - description: "Christmas holiday break.", + name: { en: "Christmas Holidays", pt: "Férias de Natal" }, + description: { en: "Christmas holiday break.", pt: "Férias de Natal." }, startDate: "2025-12-22T00:00:00Z", endDate: "2026-01-02T23:59:59Z", year: null, }, { - name: "End of classes 1st Year/Bachelor", - description: "End of classes for 1st year Bachelor students.", + name: { + en: "End of classes 1st Year/Bachelor", + pt: "Fim das aulas 1º ano/Licenciatura", + }, + description: { + en: "End of classes for 1st year Bachelor students.", + pt: "Fim das aulas para estudantes de 1º ano de Licenciatura.", + }, startDate: "2026-01-09T18:00:00Z", endDate: null, year: null, }, { - name: "End of classes, others", - description: "End of classes for all other students.", + name: { en: "End of classes, others", pt: "Fim das aulas, restantes" }, + description: { + en: "End of classes for all other students.", + pt: "Fim das aulas para todos os restantes estudantes.", + }, startDate: "2025-12-19T18:00:00Z", endDate: null, year: null, }, { - name: "Exams, 1st Year/Bachelor", - description: "Exam period for 1st year Bachelor students.", + name: { en: "Exams, 1st Year/Bachelor", pt: "Exames 1º ano/Licenciatura" }, + description: { + en: "Exam period for 1st year Bachelor students.", + pt: "Período de exames para estudantes de 1º ano de Licenciatura.", + }, startDate: "2026-01-12T09:00:00Z", endDate: "2026-02-06T18:00:00Z", year: null, }, { - name: "Exams, others", - description: "Exam period for all other students.", + name: { en: "Exams, others", pt: "Exames, restantes" }, + description: { + en: "Exam period for all other students.", + pt: "Período de exames para todos os restantes estudantes.", + }, startDate: "2026-01-05T09:00:00Z", endDate: "2026-02-06T18:00:00Z", year: null, }, { - name: "Publication marks", - description: "Publication of marks for the semester.", + name: { en: "Publication marks", pt: "Publicação de notas" }, + description: { + en: "Publication of marks for the semester.", + pt: "Publicação de notas do semestre.", + }, startDate: "2026-02-20T10:00:00Z", endDate: null, year: null, }, { - name: "Special examination period, completion of cycle of studies, 1st call", - description: "Special exam period for completion of studies (1st call).", + name: { + en: "Special examination period, completion of cycle of studies, 1st call", + pt: "Época especial, conclusão de ciclo de estudos, 1ª chamada", + }, + description: { + en: "Special exam period for completion of studies (1st call).", + pt: "Época especial de exames para conclusão de estudos (1ª chamada).", + }, startDate: "2026-03-02T09:00:00Z", endDate: "2026-03-13T18:00:00Z", year: null, }, { - name: "Publication marks, special exam. completion of cycle of studies, 1st call", - description: - "Publication of marks for special exam (completion of studies, 1st call).", + name: { + en: "Publication marks, special exam. completion of cycle of studies, 1st call", + pt: "Publicação de notas, época especial conclusão de estudos, 1ª chamada", + }, + description: { + en: "Publication of marks for special exam (completion of studies, 1st call).", + pt: "Publicação de notas da época especial (conclusão de estudos, 1ª chamada).", + }, startDate: "2026-03-20T10:00:00Z", endDate: null, year: null, }, { - name: "Special examination period, legally applicable status, 1st semester", - description: - "Special exam period for legally applicable status, 1st semester (until deadline).", + name: { + en: "Special examination period, legally applicable status, 1st semester", + pt: "Época especial, situação legalmente prevista, 1º semestre", + }, + description: { + en: "Special exam period for legally applicable status, 1st semester (until deadline).", + pt: "Época especial de exames para situação legalmente prevista, 1º semestre (até ao prazo).", + }, startDate: null, endDate: "2026-04-30T23:59:59Z", year: null, }, { - name: "Publication marks, special exam. legally applicable status, 1st semester", - description: - "Publication of marks for special exam, legally applicable status, 1st semester.", + name: { + en: "Publication marks, special exam. legally applicable status, 1st semester", + pt: "Publicação de notas, época especial situação legalmente prevista, 1º semestre", + }, + description: { + en: "Publication of marks for special exam, legally applicable status, 1st semester.", + pt: "Publicação de notas da época especial, situação legalmente prevista, 1º semestre.", + }, startDate: "2026-05-13T10:00:00Z", endDate: null, year: null, }, { - name: "Start of classes", - description: "Start of classes for the 2nd semester.", + name: { en: "Start of classes", pt: "Início das aulas" }, + description: { + en: "Start of classes for the 2nd semester.", + pt: "Início das aulas do 2º semestre.", + }, startDate: "2026-02-18T09:00:00Z", endDate: null, year: null, }, { - name: "Carnival Holidays", - description: "Carnival holiday break.", + name: { en: "Carnival Holidays", pt: "Férias de Carnaval" }, + description: { en: "Carnival holiday break.", pt: "Férias de Carnaval." }, startDate: "2026-02-16T00:00:00Z", endDate: "2026-02-17T23:59:59Z", year: null, }, { - name: "U.Porto Day", - description: "U.Porto Day celebration. Date to be defined.", + name: { en: "U.Porto Day", pt: "Dia da U.Porto" }, + description: { + en: "U.Porto Day celebration. Date to be defined.", + pt: "Celebração do Dia da U.Porto. Data a definir.", + }, startDate: null, endDate: null, year: null, }, { - name: "Easter Holidays", - description: "Easter holiday break.", + name: { en: "Easter Holidays", pt: "Férias da Páscoa" }, + description: { en: "Easter holiday break.", pt: "Férias da Páscoa." }, startDate: "2026-03-30T00:00:00Z", endDate: "2026-04-06T23:59:59Z", year: null, }, { - name: "Academic and Staff mobil. week", - description: "Academic and staff mobility week.", + name: { + en: "Academic and Staff mobil. week", + pt: "Semana de mobilidade académica e de pessoal", + }, + description: { + en: "Academic and staff mobility week.", + pt: "Semana de mobilidade académica e de pessoal.", + }, startDate: "2026-05-04T09:00:00Z", endDate: "2026-05-08T18:00:00Z", year: null, }, { - name: "End of classes", - description: "End of classes for the 2nd semester.", + name: { en: "End of classes", pt: "Fim das aulas" }, + description: { + en: "End of classes for the 2nd semester.", + pt: "Fim das aulas do 2º semestre.", + }, startDate: "2026-06-05T18:00:00Z", endDate: null, year: null, }, { - name: "Exams", - description: "Exam period for the 2nd semester.", + name: { en: "Exams", pt: "Exames" }, + description: { + en: "Exam period for the 2nd semester.", + pt: "Período de exames do 2º semestre.", + }, startDate: "2026-06-08T09:00:00Z", endDate: "2026-07-14T18:00:00Z", year: null, }, { - name: "Publication marks", - description: "Publication of marks for the 2nd semester.", + name: { en: "Publication marks", pt: "Publicação de notas" }, + description: { + en: "Publication of marks for the 2nd semester.", + pt: "Publicação de notas do 2º semestre.", + }, startDate: "2026-07-21T10:00:00Z", endDate: null, year: null, }, { - name: "Special examination period, completion of cycle of studies, 2nd call", - description: "Special exam period for completion of studies (2nd call).", + name: { + en: "Special examination period, completion of cycle of studies, 2nd call", + pt: "Época especial, conclusão de ciclo de estudos, 2ª chamada", + }, + description: { + en: "Special exam period for completion of studies (2nd call).", + pt: "Época especial de exames para conclusão de estudos (2ª chamada).", + }, startDate: "2026-09-01T09:00:00Z", endDate: "2026-09-11T18:00:00Z", year: null, }, { - name: "Publication marks, special exam. completion of cycle of studies, 2nd semester call", - description: - "Publication of marks for special exam (completion of studies, 2nd semester call).", + name: { + en: "Publication marks, special exam. completion of cycle of studies, 2nd semester call", + pt: "Publicação de notas, época especial conclusão de estudos, 2ª chamada", + }, + description: { + en: "Publication of marks for special exam (completion of studies, 2nd semester call).", + pt: "Publicação de notas da época especial (conclusão de estudos, 2ª chamada).", + }, startDate: "2026-09-18T10:00:00Z", endDate: null, year: null, }, { - name: "Special examination period, legally applicable status, 2nd semester", - description: - "Special exam period for legally applicable status, 2nd semester (until deadline).", + name: { + en: "Special examination period, legally applicable status, 2nd semester", + pt: "Época especial, situação legalmente prevista, 2º semestre", + }, + description: { + en: "Special exam period for legally applicable status, 2nd semester (until deadline).", + pt: "Época especial de exames para situação legalmente prevista, 2º semestre (até ao prazo).", + }, startDate: null, endDate: "2026-11-27T23:59:59Z", year: null, }, { - name: "Publication of marks, special exam. legally applicable status, 2nd", - description: - "Publication of marks for special exam, legally applicable status, 2nd semester.", + name: { + en: "Publication of marks, special exam. legally applicable status, 2nd", + pt: "Publicação de notas, época especial situação legalmente prevista, 2º semestre", + }, + description: { + en: "Publication of marks for special exam, legally applicable status, 2nd semester.", + pt: "Publicação de notas da época especial, situação legalmente prevista, 2º semestre.", + }, startDate: "2026-12-11T10:00:00Z", endDate: null, year: null, @@ -212,10 +320,21 @@ export default setSeederFactory(Event, () => { const template = faker.helpers.arrayElement(eventTemplates); const event = new Event(); - event.name = template.name; - event.description = template.description; + event.defaultLanguage = "en"; event.startDate = template.startDate ? new Date(template.startDate) : null; event.endDate = template.endDate ? new Date(template.endDate) : null; + const enTranslation = new EventTranslation(); + enTranslation.languageCode = "en"; + enTranslation.name = template.name.en; + enTranslation.description = template.description.en; + + const ptTranslation = new EventTranslation(); + ptTranslation.languageCode = "pt"; + ptTranslation.name = template.name.pt; + ptTranslation.description = template.description.pt; + + event.translations = [enTranslation, ptTranslation]; + return event; }); diff --git a/src/database/factories/faculty.factory.spec.ts b/src/database/factories/faculty.factory.spec.ts index 105d0e2f..697c718d 100644 --- a/src/database/factories/faculty.factory.spec.ts +++ b/src/database/factories/faculty.factory.spec.ts @@ -31,8 +31,10 @@ describe("FacultyFactory", () => { const faculty = factory.factoryFn(); expect(faculty).toBeInstanceOf(Faculty); - expect(faculty.name).toBeTruthy(); - expect(faculty.acronym).toBeTruthy(); - expect(faculty.acronym).toMatch(/U/); + expect(faculty.translations).toBeDefined(); + expect(faculty.translations.length).toBeGreaterThan(0); + expect(faculty.translations[0].name).toBeTruthy(); + expect(faculty.translations[0].acronym).toBeTruthy(); + expect(faculty.translations[0].acronym).toMatch(/U/); }); }); diff --git a/src/database/factories/faculty.factory.ts b/src/database/factories/faculty.factory.ts index 3b2b85a9..ec0e3d0b 100644 --- a/src/database/factories/faculty.factory.ts +++ b/src/database/factories/faculty.factory.ts @@ -1,31 +1,34 @@ import { faker } from "@faker-js/faker"; import { setSeederFactory } from "typeorm-extension"; import { Faculty } from "@/faculties/entities/faculty.entity"; +import { FacultyTranslation } from "@/i18n/entities"; const facultyTypes = ["Faculty", "School", "College"]; -const facultyNames = [ - "Engineering", - "Arts", - "Science", - "Business", - "Medicine", - "Law", - "Education", - "Philosophy", - "Fine Arts", - "Computer Science", - "Economics", -]; +const facultyFields: Record = { + Engineering: { en: "Engineering", pt: "Engenharia" }, + Arts: { en: "Arts", pt: "Artes" }, + Science: { en: "Science", pt: "Ciências" }, + Business: { en: "Business", pt: "Economia" }, + Medicine: { en: "Medicine", pt: "Medicina" }, + Law: { en: "Law", pt: "Direito" }, + Education: { en: "Education", pt: "Educação" }, + Philosophy: { en: "Philosophy", pt: "Filosofia" }, + "Fine Arts": { en: "Fine Arts", pt: "Belas-Artes" }, + "Computer Science": { en: "Computer Science", pt: "Informática" }, + Economics: { en: "Economics", pt: "Economia" }, +}; export default setSeederFactory(Faculty, () => { const type = faker.helpers.arrayElement(facultyTypes); - const field = faker.helpers.arrayElement(facultyNames); + const fieldKey = faker.helpers.arrayElement(Object.keys(facultyFields)); + const field = facultyFields[fieldKey]; const city = faker.location.city().replace(/[^a-zA-Z ]/g, ""); - const fullName = `${type} of ${field} of the University of ${city}`; + const fullName = `${type} of ${field.en} of the University of ${city}`; + const fullNamePt = `${type} de ${field.pt} da Universidade de ${city}`; - const fieldAcronym = field + const fieldAcronym = fieldKey .split(" ") .filter(Boolean) .map((word) => word[0]) @@ -40,8 +43,19 @@ export default setSeederFactory(Faculty, () => { const acronym = `${type[0]}${fieldAcronym}U${cityAcronym}`.toUpperCase(); const faculty = new Faculty(); - faculty.name = fullName; - faculty.acronym = acronym; + faculty.defaultLanguage = "en"; + + const enTranslation = new FacultyTranslation(); + enTranslation.languageCode = "en"; + enTranslation.name = fullName; + enTranslation.acronym = acronym; + + const ptTranslation = new FacultyTranslation(); + ptTranslation.languageCode = "pt"; + ptTranslation.name = fullNamePt; + ptTranslation.acronym = acronym; + + faculty.translations = [enTranslation, ptTranslation]; return faculty; }); diff --git a/src/database/seed.ts b/src/database/seed.ts index fe66cd0a..d9bed9f5 100644 --- a/src/database/seed.ts +++ b/src/database/seed.ts @@ -3,6 +3,11 @@ import { runSeeders, SeederOptions } from "typeorm-extension"; import { Course } from "@/courses/entities/course.entity"; import { Event } from "@/events/entities/event.entity"; import { Faculty } from "@/faculties/entities/faculty.entity"; +import { + CourseTranslation, + EventTranslation, + FacultyTranslation, +} from "@/i18n/entities"; import { Schedule } from "@/services/entity/schedule.entity"; import { Service } from "@/services/entity/service.entity"; import { User } from "@/users/entities/user.entity"; @@ -17,8 +22,17 @@ export const seed = async () => { database: process.env.DATABASE_NAME || "niddle_db", synchronize: true, dropSchema: true, - schema: "public", - entities: [Course, Faculty, User, Service, Schedule, Event], + entities: [ + Course, + Faculty, + User, + Event, + Service, + Schedule, + CourseTranslation, + FacultyTranslation, + EventTranslation, + ], seeds: ["src/database/seeds/*.seeder.{ts,js}"], factories: ["src/database/factories/*.factory.{ts,js}"], }; diff --git a/src/database/seeds/event.seeder.spec.ts b/src/database/seeds/event.seeder.spec.ts index f9ecbd68..468c609c 100644 --- a/src/database/seeds/event.seeder.spec.ts +++ b/src/database/seeds/event.seeder.spec.ts @@ -13,15 +13,33 @@ describe("EventSeeder", () => { const mockFaculties: Faculty[] = [ { id: 1, - name: "Engineering Faculty", - acronym: "FEUP", + defaultLanguage: "en", + translations: [ + { + id: 1, + facultyId: 1, + languageCode: "en", + name: "Engineering Faculty", + acronym: "FEUP", + faculty: undefined as any, + }, + ], courses: [], events: [], }, { id: 2, - name: "Science Faculty", - acronym: "FCUP", + defaultLanguage: "en", + translations: [ + { + id: 2, + facultyId: 2, + languageCode: "en", + name: "Science Faculty", + acronym: "FCUP", + faculty: undefined as any, + }, + ], courses: [], events: [], }, @@ -30,15 +48,33 @@ describe("EventSeeder", () => { const mockCourses: Course[] = [ { id: 1, - name: "Computer Science", - acronym: "CS", + defaultLanguage: "en", + translations: [ + { + id: 1, + courseId: 1, + languageCode: "en", + name: "Computer Science", + acronym: "CS", + course: undefined as any, + }, + ], faculties: [], events: [], }, { id: 2, - name: "Engineering", - acronym: "ENG", + defaultLanguage: "en", + translations: [ + { + id: 2, + courseId: 2, + languageCode: "en", + name: "Engineering", + acronym: "ENG", + course: undefined as any, + }, + ], faculties: [], events: [], }, @@ -46,8 +82,17 @@ describe("EventSeeder", () => { const mockEvent: Event = { id: 1, - name: "FEUP Week", - description: "Annual FEUP event", + defaultLanguage: "en", + translations: [ + { + id: 1, + eventId: 1, + languageCode: "en", + name: "FEUP Week", + description: "Annual FEUP event", + event: undefined as any, + }, + ], year: 2025, startDate: new Date("2025-12-26T09:00:00Z"), endDate: new Date("2025-12-27T18:00:00Z"), diff --git a/src/events/dto/create-event.dto.spec.ts b/src/events/dto/create-event.dto.spec.ts new file mode 100644 index 00000000..94b686ca --- /dev/null +++ b/src/events/dto/create-event.dto.spec.ts @@ -0,0 +1,70 @@ +import "reflect-metadata"; +import { plainToInstance } from "class-transformer"; +import { validate } from "class-validator"; +import { CreateEventDto, EventTranslationDto } from "./create-event.dto"; + +describe("CreateEventDto", () => { + it("should be defined", () => { + expect(CreateEventDto).toBeDefined(); + }); + + it("should have translations property", () => { + const dto = new CreateEventDto(); + expect(dto.translations).toBeUndefined(); + }); + + it("should validate translations using class-transformer", () => { + const plain = { + translations: [ + { languageCode: "en", name: "Event", description: "Desc" }, + ], + year: 2025, + }; + const dto = plainToInstance(CreateEventDto, plain); + expect(dto.translations).toHaveLength(1); + expect(dto.translations[0].languageCode).toBe("en"); + }); + + it("should allow setting year", () => { + const dto = new CreateEventDto(); + dto.year = 2025; + expect(dto.year).toBe(2025); + }); + + it("should allow setting facultyId", () => { + const dto = new CreateEventDto(); + dto.facultyId = 1; + expect(dto.facultyId).toBe(1); + }); + + it("should allow setting courseIds", () => { + const dto = new CreateEventDto(); + dto.courseIds = [1, 2]; + expect(dto.courseIds).toEqual([1, 2]); + }); + + it("should validate valid DTO", async () => { + const dto = plainToInstance(CreateEventDto, { + translations: [{ languageCode: "en", name: "Event" }], + year: 2025, + }); + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); +}); + +describe("EventTranslationDto", () => { + it("should be defined", () => { + expect(EventTranslationDto).toBeDefined(); + }); + + it("should have languageCode, name, description properties", () => { + const dto = new EventTranslationDto(); + dto.languageCode = "en"; + dto.name = "Event"; + dto.description = "Description"; + expect(dto.languageCode).toBe("en"); + expect(dto.name).toBe("Event"); + expect(dto.description).toBe("Description"); + }); +}); diff --git a/src/events/dto/create-event.dto.ts b/src/events/dto/create-event.dto.ts index 8916ad65..78ead4b9 100644 --- a/src/events/dto/create-event.dto.ts +++ b/src/events/dto/create-event.dto.ts @@ -1,65 +1,46 @@ +import { Type } from "class-transformer"; import { IsArray, IsDateString, IsInt, - IsNotEmpty, IsOptional, IsString, + ValidateNested, } from "class-validator"; -export class CreateEventDto { - /** - * The event name. - * @example 'FEUP Week' - */ +export class EventTranslationDto { + @IsString() + languageCode: string; + @IsString() - @IsNotEmpty() name: string; - /** - * The event description. - * @example 'FEUP week is a period of interruption of classes and teaching mobility. It includes teaching activities (visits, exhibitions, lectures, ...), as well as the FEUP Project Congress.' - */ @IsString() @IsOptional() description?: string; +} + +export class CreateEventDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => EventTranslationDto) + translations: EventTranslationDto[]; - /** - * The year when the event occurs. - * @example 2025 - */ @IsInt() - @IsNotEmpty() year: number; - /** - * The start date and time of the event. - * @example '2025-12-26T09:00:00Z' - */ @IsDateString() @IsOptional() startDate?: string; - /** - * The end date and time of the event. - * @example '2025-12-27T18:00:00Z' - */ @IsDateString() @IsOptional() endDate?: string; - /** - * The faculty ID this event belongs to. - * @example 1 - */ @IsInt() @IsOptional() facultyId?: number; - /** - * The course IDs associated with this event. - * @example [1, 2] - */ @IsArray() @IsInt({ each: true }) @IsOptional() diff --git a/src/events/entities/event.entity.ts b/src/events/entities/event.entity.ts index 4faabc22..26b452ed 100644 --- a/src/events/entities/event.entity.ts +++ b/src/events/entities/event.entity.ts @@ -4,10 +4,12 @@ import { JoinTable, ManyToMany, ManyToOne, + OneToMany, PrimaryGeneratedColumn, } from "typeorm"; import { Course } from "@/courses/entities/course.entity"; import { Faculty } from "@/faculties/entities/faculty.entity"; +import { EventTranslation } from "@/i18n/entities"; @Entity() export class Event { @@ -19,18 +21,21 @@ export class Event { id: number; /** - * The event name. - * @example 'FEUP Week' + * The default language for this event's translations. + * @example "en" */ - @Column() - name: string; + @Column({ name: "default_language", length: 10, default: "en" }) + defaultLanguage: string; /** - * The event description. - * @example 'FEUP week is a period of interruption of classes and teaching mobility. It includes teaching activities (visits, exhibitions, lectures, ...), as well as the FEUP Project Congress.' + * Translations for the event name and description. */ - @Column({ nullable: true }) - description?: string; + @OneToMany( + () => EventTranslation, + (translation) => translation.event, + { cascade: true }, + ) + translations: EventTranslation[]; /** * The year when the event occurs. diff --git a/src/events/events.controller.spec.ts b/src/events/events.controller.spec.ts index a83ad9d3..16344ba8 100644 --- a/src/events/events.controller.spec.ts +++ b/src/events/events.controller.spec.ts @@ -12,8 +12,17 @@ describe("EventsController", () => { const mockEvent: Event = { id: 1, - name: "FEUP Week", - description: "Annual FEUP event", + defaultLanguage: "en", + translations: [ + { + id: 1, + eventId: 1, + languageCode: "en", + name: "FEUP Week", + description: "Annual FEUP event", + event: undefined as any, + }, + ], year: 2025, startDate: new Date("2025-12-26T09:00:00Z"), endDate: new Date("2025-12-27T18:00:00Z"), @@ -85,8 +94,13 @@ describe("EventsController", () => { describe("create", () => { it("should create a new event", async () => { const createEventDto: CreateEventDto = { - name: "FEUP Week", - description: "Annual FEUP event", + translations: [ + { + languageCode: "en", + name: "FEUP Week", + description: "Annual FEUP event", + }, + ], year: 2025, startDate: "2025-12-26T09:00:00Z", endDate: "2025-12-27T18:00:00Z", @@ -104,9 +118,15 @@ describe("EventsController", () => { describe("update", () => { it("should update an event", async () => { const updateEventDto: UpdateEventDto = { - name: "Updated Event Name", + translations: [ + { + languageCode: "en", + name: "Updated Event Name", + description: "Annual FEUP event", + }, + ], }; - const updatedEvent = { ...mockEvent, ...updateEventDto }; + const updatedEvent = { ...mockEvent }; mockEventsService.update.mockResolvedValue(updatedEvent); const result = await controller.update(1, updateEventDto); @@ -117,7 +137,13 @@ describe("EventsController", () => { it("should throw NotFoundException if event not found", async () => { const updateEventDto: UpdateEventDto = { - name: "Updated Event Name", + translations: [ + { + languageCode: "en", + name: "Updated Event Name", + description: "Annual FEUP event", + }, + ], }; mockEventsService.update.mockRejectedValue(new NotFoundException()); diff --git a/src/events/events.module.spec.ts b/src/events/events.module.spec.ts index 6800de7e..627867c8 100644 --- a/src/events/events.module.spec.ts +++ b/src/events/events.module.spec.ts @@ -3,6 +3,11 @@ import { TypeOrmModule } from "@nestjs/typeorm"; import { Course } from "@/courses/entities/course.entity"; import { Event } from "@/events/entities/event.entity"; import { Faculty } from "@/faculties/entities/faculty.entity"; +import { + CourseTranslation, + EventTranslation, + FacultyTranslation, +} from "@/i18n/entities"; import { EventsController } from "./events.controller"; import { EventsModule } from "./events.module"; import { EventsService } from "./events.service"; @@ -16,7 +21,14 @@ describe("EventsModule", () => { TypeOrmModule.forRoot({ type: "sqlite", database: ":memory:", - entities: [Course, Faculty, Event], + entities: [ + Course, + CourseTranslation, + Event, + EventTranslation, + Faculty, + FacultyTranslation, + ], synchronize: true, }), EventsModule, diff --git a/src/events/events.module.ts b/src/events/events.module.ts index 5a297a3c..c6db4555 100644 --- a/src/events/events.module.ts +++ b/src/events/events.module.ts @@ -2,12 +2,15 @@ import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; import { Course } from "@/courses/entities/course.entity"; import { Faculty } from "@/faculties/entities/faculty.entity"; +import { EventTranslation } from "@/i18n/entities"; import { Event } from "./entities/event.entity"; import { EventsController } from "./events.controller"; import { EventsService } from "./events.service"; @Module({ - imports: [TypeOrmModule.forFeature([Event, Faculty, Course])], + imports: [ + TypeOrmModule.forFeature([Event, Faculty, Course, EventTranslation]), + ], controllers: [EventsController], providers: [EventsService], }) diff --git a/src/events/events.service.spec.ts b/src/events/events.service.spec.ts index 1b369751..e7f585ce 100644 --- a/src/events/events.service.spec.ts +++ b/src/events/events.service.spec.ts @@ -13,8 +13,17 @@ describe("EventsService", () => { const mockEvent: Event = { id: 1, - name: "FEUP Week", - description: "Annual FEUP event", + defaultLanguage: "en", + translations: [ + { + id: 1, + eventId: 1, + languageCode: "en", + name: "FEUP Week", + description: "Annual FEUP event", + event: undefined as any, + }, + ], year: 2025, startDate: new Date("2025-12-26T09:00:00Z"), endDate: new Date("2025-12-27T18:00:00Z"), @@ -24,16 +33,34 @@ describe("EventsService", () => { const mockFaculty: Faculty = { id: 1, - name: "Engineering Faculty", - acronym: "FEUP", + defaultLanguage: "en", + translations: [ + { + id: 1, + facultyId: 1, + languageCode: "en", + name: "Engineering Faculty", + acronym: "FEUP", + faculty: undefined as any, + }, + ], courses: [], events: [], }; const mockCourse: Course = { id: 1, - name: "Computer Science", - acronym: "CS", + defaultLanguage: "en", + translations: [ + { + id: 1, + courseId: 1, + languageCode: "en", + name: "Computer Science", + acronym: "CS", + course: undefined as any, + }, + ], faculties: [], events: [], }; @@ -98,7 +125,7 @@ describe("EventsService", () => { expect(result).toEqual(events); expect(mockEventRepository.find).toHaveBeenCalledWith({ where: {}, - relations: ["faculty", "courses"], + relations: ["translations", "faculty", "courses"], }); }); @@ -112,7 +139,7 @@ describe("EventsService", () => { expect(result).toEqual(events); expect(mockEventRepository.find).toHaveBeenCalledWith({ where: { year: 2025 }, - relations: ["faculty", "courses"], + relations: ["translations", "faculty", "courses"], }); }); @@ -126,7 +153,7 @@ describe("EventsService", () => { expect(result).toEqual(events); expect(mockEventRepository.find).toHaveBeenCalledWith({ where: { faculty: { id: 1 } }, - relations: ["faculty", "courses"], + relations: ["translations", "faculty", "courses"], }); }); @@ -140,7 +167,7 @@ describe("EventsService", () => { expect(result).toEqual(events); expect(mockEventRepository.find).toHaveBeenCalledWith({ where: { courses: { id: 1 } }, - relations: ["faculty", "courses"], + relations: ["translations", "faculty", "courses"], }); }); @@ -154,7 +181,7 @@ describe("EventsService", () => { expect(result).toEqual(events); expect(mockEventRepository.find).toHaveBeenCalledWith({ where: { year: 2025, faculty: { id: 1 } }, - relations: ["faculty", "courses"], + relations: ["translations", "faculty", "courses"], }); }); @@ -168,7 +195,7 @@ describe("EventsService", () => { expect(result).toEqual(events); expect(mockEventRepository.find).toHaveBeenCalledWith({ where: { year: 2025, courses: { id: 1 } }, - relations: ["faculty", "courses"], + relations: ["translations", "faculty", "courses"], }); }); @@ -182,7 +209,7 @@ describe("EventsService", () => { expect(result).toEqual(events); expect(mockEventRepository.find).toHaveBeenCalledWith({ where: { faculty: { id: 1 }, courses: { id: 1 } }, - relations: ["faculty", "courses"], + relations: ["translations", "faculty", "courses"], }); }); @@ -196,7 +223,7 @@ describe("EventsService", () => { expect(result).toEqual(events); expect(mockEventRepository.find).toHaveBeenCalledWith({ where: { year: 2025, faculty: { id: 1 }, courses: { id: 1 } }, - relations: ["faculty", "courses"], + relations: ["translations", "faculty", "courses"], }); }); }); @@ -210,7 +237,7 @@ describe("EventsService", () => { expect(result).toEqual(mockEvent); expect(mockEventRepository.findOneOrFail).toHaveBeenCalledWith({ where: { id: 1 }, - relations: ["faculty", "courses"], + relations: ["translations", "faculty", "courses"], }); }); @@ -226,32 +253,47 @@ describe("EventsService", () => { describe("create", () => { it("should create an event without faculty", async () => { const createEventDto: CreateEventDto = { - name: "FEUP Week", - description: "Annual FEUP event", + translations: [ + { + languageCode: "en", + name: "FEUP Week", + description: "Annual FEUP event", + }, + ], year: 2025, startDate: "2025-12-26T09:00:00Z", endDate: "2025-12-27T18:00:00Z", }; - mockEventRepository.create.mockReturnValue(mockEvent); mockEventRepository.save.mockResolvedValue(mockEvent); const result = await service.create(createEventDto); expect(result).toEqual(mockEvent); - expect(mockEventRepository.create).toHaveBeenCalledWith({ - name: "FEUP Week", - description: "Annual FEUP event", + expect(mockEventRepository.save).toHaveBeenCalled(); + }); + + it("should create an event with empty translations array", async () => { + const createEventDto: CreateEventDto = { + translations: [], year: 2025, - startDate: "2025-12-26T09:00:00Z", - endDate: "2025-12-27T18:00:00Z", - }); - expect(mockEventRepository.save).toHaveBeenCalledWith(mockEvent); + }; + mockEventRepository.save.mockResolvedValue(mockEvent); + + const result = await service.create(createEventDto); + + expect(result).toEqual(mockEvent); + expect(mockEventRepository.save).toHaveBeenCalled(); }); it("should create an event with valid faculty", async () => { const createEventDto: CreateEventDto = { - name: "FEUP Week", - description: "Annual FEUP event", + translations: [ + { + languageCode: "en", + name: "FEUP Week", + description: "Annual FEUP event", + }, + ], year: 2025, startDate: "2025-12-26T09:00:00Z", endDate: "2025-12-27T18:00:00Z", @@ -272,14 +314,36 @@ describe("EventsService", () => { }); }); + it("should create an event with translation without description", async () => { + const createEventDto: CreateEventDto = { + translations: [ + { + languageCode: "en", + name: "FEUP Week", + }, + ], + year: 2025, + }; + mockEventRepository.save.mockResolvedValue(mockEvent); + + const result = await service.create(createEventDto); + + expect(result).toEqual(mockEvent); + expect(mockEventRepository.save).toHaveBeenCalled(); + }); + it("should throw NotFoundException if faculty is not found", async () => { const createEventDto: CreateEventDto = { - name: "FEUP Week", - description: "Annual FEUP event", + translations: [ + { + languageCode: "en", + name: "FEUP Week", + description: "Annual FEUP event", + }, + ], year: 2025, facultyId: 999, }; - mockEventRepository.create.mockReturnValue(mockEvent); mockFacultyRepository.findOneByOrFail.mockRejectedValue( new Error("Not found"), ); @@ -289,12 +353,16 @@ describe("EventsService", () => { it("should create an event with valid courses", async () => { const createEventDto: CreateEventDto = { - name: "FEUP Week", - description: "Annual FEUP event", + translations: [ + { + languageCode: "en", + name: "FEUP Week", + description: "Annual FEUP event", + }, + ], year: 2025, courseIds: [1], }; - mockEventRepository.create.mockReturnValue({ ...mockEvent }); mockCourseRepository.findBy.mockResolvedValue([mockCourse]); mockEventRepository.save.mockResolvedValue({ ...mockEvent, @@ -309,12 +377,16 @@ describe("EventsService", () => { it("should throw NotFoundException if any course is not found", async () => { const createEventDto: CreateEventDto = { - name: "FEUP Week", - description: "Annual FEUP event", + translations: [ + { + languageCode: "en", + name: "FEUP Week", + description: "Annual FEUP event", + }, + ], year: 2025, courseIds: [1, 2], }; - mockEventRepository.create.mockReturnValue(mockEvent); mockCourseRepository.findBy.mockResolvedValue([mockCourse]); await expect(service.create(createEventDto)).rejects.toThrow(); @@ -323,17 +395,21 @@ describe("EventsService", () => { describe("update", () => { it("should update an event successfully", async () => { - const updateEventDto: UpdateEventDto = { name: "New Event Name" }; + const updateEventDto: UpdateEventDto = { + translations: [ + { + languageCode: "en", + name: "New Event Name", + description: "Annual FEUP event", + }, + ], + }; mockEventRepository.findOneByOrFail.mockResolvedValue({ ...mockEvent }); - mockEventRepository.save.mockResolvedValue({ - ...mockEvent, - name: "New Event Name", - }); + mockEventRepository.save.mockResolvedValue({ ...mockEvent }); const result = await service.update(1, updateEventDto); - expect(result.name).toEqual("New Event Name"); - expect(mockEventRepository.merge).toHaveBeenCalled(); + expect(result).toBeDefined(); expect(mockEventRepository.save).toHaveBeenCalled(); }); diff --git a/src/events/events.service.ts b/src/events/events.service.ts index 1cd1a1d6..2ab948a1 100644 --- a/src/events/events.service.ts +++ b/src/events/events.service.ts @@ -4,6 +4,7 @@ import { Repository } from "typeorm"; import { validateAndGetRelations } from "@/common/utils/entity-relation.utils"; import { Course } from "@/courses/entities/course.entity"; import { Faculty } from "@/faculties/entities/faculty.entity"; +import { EventTranslation } from "@/i18n/entities"; import { CreateEventDto } from "./dto/create-event.dto"; import { EventFilterDto } from "./dto/event-filter.dto"; import { UpdateEventDto } from "./dto/update-event.dto"; @@ -21,8 +22,19 @@ export class EventsService { ) {} async create(createEventDto: CreateEventDto): Promise { - const { facultyId, courseIds, ...eventData } = createEventDto; - const event = this.eventRepository.create(eventData); + const { facultyId, courseIds, translations } = createEventDto; + const event = new Event(); + event.defaultLanguage = "en"; + + if (translations && translations.length > 0) { + event.translations = translations.map((t) => { + const translation = new EventTranslation(); + translation.languageCode = t.languageCode; + translation.name = t.name; + translation.description = t.description ?? null; + return translation; + }); + } if (facultyId !== undefined) { event.faculty = await this.facultyRepository.findOneByOrFail({ @@ -50,22 +62,21 @@ export class EventsService { ...(facultyId && { faculty: { id: facultyId } }), ...(courseId && { courses: { id: courseId } }), }, - relations: ["faculty", "courses"], + relations: ["translations", "faculty", "courses"], }); } findOne(id: number): Promise { return this.eventRepository.findOneOrFail({ where: { id }, - relations: ["faculty", "courses"], + relations: ["translations", "faculty", "courses"], }); } async update(id: number, updateEventDto: UpdateEventDto): Promise { - const { facultyId, courseIds, ...eventData } = updateEventDto; + const { facultyId, courseIds } = updateEventDto; const event = await this.eventRepository.findOneByOrFail({ id }); - this.eventRepository.merge(event, eventData); if (facultyId !== undefined) { event.faculty = await this.facultyRepository.findOneByOrFail({ diff --git a/src/faculties/dto/create-faculty.dto.spec.ts b/src/faculties/dto/create-faculty.dto.spec.ts new file mode 100644 index 00000000..3c8e1502 --- /dev/null +++ b/src/faculties/dto/create-faculty.dto.spec.ts @@ -0,0 +1,54 @@ +import "reflect-metadata"; +import { plainToInstance } from "class-transformer"; +import { validate } from "class-validator"; +import { CreateFacultyDto, FacultyTranslationDto } from "./create-faculty.dto"; + +describe("CreateFacultyDto", () => { + it("should be defined", () => { + expect(CreateFacultyDto).toBeDefined(); + }); + + it("should have translations property", () => { + const dto = new CreateFacultyDto(); + expect(dto.translations).toBeUndefined(); + }); + + it("should validate translations using class-transformer", () => { + const plain = { + translations: [{ languageCode: "en", name: "Faculty", acronym: "F" }], + }; + const dto = plainToInstance(CreateFacultyDto, plain); + expect(dto.translations).toHaveLength(1); + expect(dto.translations[0].languageCode).toBe("en"); + }); + + it("should allow setting courseIds", () => { + const dto = new CreateFacultyDto(); + dto.courseIds = [1, 2]; + expect(dto.courseIds).toEqual([1, 2]); + }); + + it("should validate valid DTO", async () => { + const dto = plainToInstance(CreateFacultyDto, { + translations: [{ languageCode: "en", name: "Faculty", acronym: "F" }], + }); + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); +}); + +describe("FacultyTranslationDto", () => { + it("should be defined", () => { + expect(FacultyTranslationDto).toBeDefined(); + }); + + it("should have languageCode, name, acronym properties", () => { + const dto = new FacultyTranslationDto(); + dto.languageCode = "en"; + dto.name = "Faculty"; + dto.acronym = "F"; + expect(dto.languageCode).toBe("en"); + expect(dto.name).toBe("Faculty"); + expect(dto.acronym).toBe("F"); + }); +}); diff --git a/src/faculties/dto/create-faculty.dto.ts b/src/faculties/dto/create-faculty.dto.ts index f1f902c2..496323f3 100644 --- a/src/faculties/dto/create-faculty.dto.ts +++ b/src/faculties/dto/create-faculty.dto.ts @@ -1,32 +1,29 @@ +import { Type } from "class-transformer"; import { IsArray, - IsNotEmpty, IsNumber, IsOptional, IsString, + ValidateNested, } from "class-validator"; -export class CreateFacultyDto { - /** - * The name of the faculty. - * @example 'Faculty of Engineering' - */ +export class FacultyTranslationDto { + @IsString() + languageCode: string; + @IsString() - @IsNotEmpty() name: string; - /** - * The acronym of the faculty. - * @example 'FEUP' - */ @IsString() - @IsNotEmpty() acronym: string; +} + +export class CreateFacultyDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => FacultyTranslationDto) + translations: FacultyTranslationDto[]; - /** - * The IDs of the courses associated with the faculty. - * @example [1, 2] - */ @IsArray() @IsNumber({}, { each: true }) @IsOptional() diff --git a/src/faculties/entities/faculty.entity.ts b/src/faculties/entities/faculty.entity.ts index b169c841..e8e507c7 100644 --- a/src/faculties/entities/faculty.entity.ts +++ b/src/faculties/entities/faculty.entity.ts @@ -8,6 +8,7 @@ import { } from "typeorm"; import { Course } from "@/courses/entities/course.entity"; import { Event } from "@/events/entities/event.entity"; +import { FacultyTranslation } from "@/i18n/entities"; @Entity() export class Faculty { @@ -19,22 +20,24 @@ export class Faculty { id: number; /** - * The name of the faculty. - * @example 'Faculty of Engineering' + * The default language for this faculty's translations. + * @example "en" */ - @Column() - name: string; + @Column({ name: "default_language", length: 10, default: "en" }) + defaultLanguage: string; /** - * The acronym of the faculty. - * @example 'FEUP' + * Translations for the faculty name and acronym. */ - @Column() - acronym: string; + @OneToMany( + () => FacultyTranslation, + (translation) => translation.faculty, + { cascade: true }, + ) + translations: FacultyTranslation[]; /** - * The courses associated with the faculty. - * @example [{ id: 1, name: 'Bachelor in Informatics and Computing Engineering', acronym: 'LEIC' }] + * The courses associated with this faculty. */ @ManyToMany( () => Course, diff --git a/src/faculties/faculties.controller.spec.ts b/src/faculties/faculties.controller.spec.ts index 5920e6fa..f4ad4bb7 100644 --- a/src/faculties/faculties.controller.spec.ts +++ b/src/faculties/faculties.controller.spec.ts @@ -11,8 +11,17 @@ describe("FacultiesController", () => { const mockFaculty: Faculty = { id: 1, - name: "Engineering Faculty", - acronym: "FEUP", + defaultLanguage: "en", + translations: [ + { + id: 1, + facultyId: 1, + languageCode: "en", + name: "Engineering Faculty", + acronym: "FEUP", + faculty: undefined as any, + }, + ], courses: [], events: [], }; @@ -80,8 +89,9 @@ describe("FacultiesController", () => { describe("create", () => { it("should create a new faculty", async () => { const createFacultyDto: CreateFacultyDto = { - name: "Engineering Faculty", - acronym: "FEUP", + translations: [ + { languageCode: "en", name: "Engineering Faculty", acronym: "FEUP" }, + ], courseIds: [1, 2], }; mockFacultiesService.create.mockResolvedValue(mockFaculty); @@ -98,9 +108,11 @@ describe("FacultiesController", () => { describe("update", () => { it("should update a faculty", async () => { const updateFacultyDto: UpdateFacultyDto = { - name: "Updated Faculty Name", + translations: [ + { languageCode: "en", name: "Updated Faculty Name", acronym: "FEUP" }, + ], }; - const updatedFaculty = { ...mockFaculty, ...updateFacultyDto }; + const updatedFaculty = { ...mockFaculty }; mockFacultiesService.update.mockResolvedValue(updatedFaculty); const result = await controller.update(1, updateFacultyDto); @@ -114,7 +126,9 @@ describe("FacultiesController", () => { it("should throw NotFoundException if faculty not found", async () => { const updateFacultyDto: UpdateFacultyDto = { - name: "Updated Faculty Name", + translations: [ + { languageCode: "en", name: "Updated Faculty Name", acronym: "FEUP" }, + ], }; mockFacultiesService.update.mockRejectedValue(new NotFoundException()); diff --git a/src/faculties/faculties.module.spec.ts b/src/faculties/faculties.module.spec.ts index 21b2297e..df2d5d3d 100644 --- a/src/faculties/faculties.module.spec.ts +++ b/src/faculties/faculties.module.spec.ts @@ -2,6 +2,11 @@ import { Test, TestingModule } from "@nestjs/testing"; import { TypeOrmModule } from "@nestjs/typeorm"; import { Course } from "@/courses/entities/course.entity"; import { Event } from "@/events/entities/event.entity"; +import { + CourseTranslation, + EventTranslation, + FacultyTranslation, +} from "@/i18n/entities"; import { Faculty } from "./entities/faculty.entity"; import { FacultiesController } from "./faculties.controller"; import { FacultiesModule } from "./faculties.module"; @@ -16,7 +21,14 @@ describe("FacultiesModule", () => { TypeOrmModule.forRoot({ type: "sqlite", database: ":memory:", - entities: [Faculty, Course, Event], + entities: [ + Course, + CourseTranslation, + Event, + EventTranslation, + Faculty, + FacultyTranslation, + ], synchronize: true, }), FacultiesModule, diff --git a/src/faculties/faculties.module.ts b/src/faculties/faculties.module.ts index f5c8b98e..535073c5 100644 --- a/src/faculties/faculties.module.ts +++ b/src/faculties/faculties.module.ts @@ -1,12 +1,13 @@ import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; import { Course } from "@/courses/entities/course.entity"; +import { FacultyTranslation } from "@/i18n/entities"; import { Faculty } from "./entities/faculty.entity"; import { FacultiesController } from "./faculties.controller"; import { FacultiesService } from "./faculties.service"; @Module({ - imports: [TypeOrmModule.forFeature([Faculty, Course])], + imports: [TypeOrmModule.forFeature([Faculty, Course, FacultyTranslation])], controllers: [FacultiesController], providers: [FacultiesService], }) diff --git a/src/faculties/faculties.service.spec.ts b/src/faculties/faculties.service.spec.ts index fcb2fe18..fc2f4cc6 100644 --- a/src/faculties/faculties.service.spec.ts +++ b/src/faculties/faculties.service.spec.ts @@ -12,16 +12,34 @@ describe("FacultiesService", () => { const mockFaculty: Faculty = { id: 1, - name: "Engineering Faculty", - acronym: "FEUP", + defaultLanguage: "en", + translations: [ + { + id: 1, + facultyId: 1, + languageCode: "en", + name: "Engineering Faculty", + acronym: "FEUP", + faculty: undefined as any, + }, + ], courses: [], events: [], }; const mockCourse: Course = { id: 1, - name: "Computer Science", - acronym: "CS", + defaultLanguage: "en", + translations: [ + { + id: 1, + courseId: 1, + languageCode: "en", + name: "Computer Science", + acronym: "CS", + course: undefined as any, + }, + ], faculties: [], events: [], }; @@ -76,7 +94,7 @@ describe("FacultiesService", () => { expect(result).toEqual(faculties); expect(mockFacultyRepository.find).toHaveBeenCalledWith({ - relations: ["courses"], + relations: ["translations", "courses"], }); }); }); @@ -90,7 +108,7 @@ describe("FacultiesService", () => { expect(result).toEqual(mockFaculty); expect(mockFacultyRepository.findOneOrFail).toHaveBeenCalledWith({ where: { id: 1 }, - relations: ["courses"], + relations: ["translations", "courses"], }); }); @@ -106,25 +124,45 @@ describe("FacultiesService", () => { describe("create", () => { it("should create a faculty without courses", async () => { const createFacultyDto: CreateFacultyDto = { - name: "Engineering Faculty", - acronym: "FEUP", + translations: [ + { languageCode: "en", name: "Engineering Faculty", acronym: "FEUP" }, + ], }; - mockFacultyRepository.create.mockReturnValue(mockFaculty); mockFacultyRepository.save.mockResolvedValue(mockFaculty); const result = await service.create(createFacultyDto); expect(result).toEqual(mockFaculty); - expect(mockFacultyRepository.create).toHaveBeenCalledWith( - createFacultyDto, - ); - expect(mockFacultyRepository.save).toHaveBeenCalledWith(mockFaculty); + expect(mockFacultyRepository.save).toHaveBeenCalled(); + }); + + it("should create a faculty without translations", async () => { + const createFacultyDto: CreateFacultyDto = {} as CreateFacultyDto; + mockFacultyRepository.save.mockResolvedValue(mockFaculty); + + const result = await service.create(createFacultyDto); + + expect(result).toEqual(mockFaculty); + expect(mockFacultyRepository.save).toHaveBeenCalled(); + }); + + it("should create a faculty with empty translations array", async () => { + const createFacultyDto: CreateFacultyDto = { + translations: [], + }; + mockFacultyRepository.save.mockResolvedValue(mockFaculty); + + const result = await service.create(createFacultyDto); + + expect(result).toEqual(mockFaculty); + expect(mockFacultyRepository.save).toHaveBeenCalled(); }); it("should create a faculty with valid courses", async () => { const createFacultyDto: CreateFacultyDto = { - name: "Engineering Faculty", - acronym: "FEUP", + translations: [ + { languageCode: "en", name: "Engineering Faculty", acronym: "FEUP" }, + ], courseIds: [1], }; mockFacultyRepository.create.mockReturnValue({ ...mockFaculty }); @@ -142,8 +180,9 @@ describe("FacultiesService", () => { it("should throw NotFoundException if any course is not found", async () => { const createFacultyDto: CreateFacultyDto = { - name: "Engineering Faculty", - acronym: "FEUP", + translations: [ + { languageCode: "en", name: "Engineering Faculty", acronym: "FEUP" }, + ], courseIds: [1, 2], }; mockFacultyRepository.create.mockReturnValue(mockFaculty); @@ -157,19 +196,20 @@ describe("FacultiesService", () => { describe("update", () => { it("should update a faculty successfully", async () => { - const updateFacultyDto: UpdateFacultyDto = { name: "New Name" }; + const updateFacultyDto: UpdateFacultyDto = { + translations: [ + { languageCode: "en", name: "New Name", acronym: "FEUP" }, + ], + }; + const updatedFaculty = { ...mockFaculty }; mockFacultyRepository.findOneByOrFail.mockResolvedValue({ ...mockFaculty, }); - mockFacultyRepository.save.mockResolvedValue({ - ...mockFaculty, - name: "New Name", - }); + mockFacultyRepository.save.mockResolvedValue(updatedFaculty); const result = await service.update(1, updateFacultyDto); - expect(result.name).toEqual("New Name"); - expect(mockFacultyRepository.merge).toHaveBeenCalled(); + expect(result).toBeDefined(); expect(mockFacultyRepository.save).toHaveBeenCalled(); }); diff --git a/src/faculties/faculties.service.ts b/src/faculties/faculties.service.ts index e6f0f63d..b5eef9dd 100644 --- a/src/faculties/faculties.service.ts +++ b/src/faculties/faculties.service.ts @@ -3,6 +3,7 @@ import { InjectRepository } from "@nestjs/typeorm"; import { Repository } from "typeorm"; import { validateAndGetRelations } from "@/common/utils/entity-relation.utils"; import { Course } from "@/courses/entities/course.entity"; +import { FacultyTranslation } from "@/i18n/entities"; import { CreateFacultyDto } from "./dto/create-faculty.dto"; import { UpdateFacultyDto } from "./dto/update-faculty.dto"; import { Faculty } from "./entities/faculty.entity"; @@ -17,8 +18,19 @@ export class FacultiesService { ) {} async create(createFacultyDto: CreateFacultyDto): Promise { - const { courseIds, ...facultyData } = createFacultyDto; - const faculty = this.facultyRepository.create(facultyData); + const { courseIds, translations } = createFacultyDto; + const faculty = new Faculty(); + faculty.defaultLanguage = "en"; + + if (translations && translations.length > 0) { + faculty.translations = translations.map((t) => { + const translation = new FacultyTranslation(); + translation.languageCode = t.languageCode; + translation.name = t.name; + translation.acronym = t.acronym; + return translation; + }); + } if (courseIds !== undefined) { faculty.courses = await validateAndGetRelations( @@ -32,13 +44,15 @@ export class FacultiesService { } findAll(): Promise { - return this.facultyRepository.find({ relations: ["courses"] }); + return this.facultyRepository.find({ + relations: ["translations", "courses"], + }); } findOne(id: number): Promise { return this.facultyRepository.findOneOrFail({ where: { id }, - relations: ["courses"], + relations: ["translations", "courses"], }); } @@ -46,12 +60,10 @@ export class FacultiesService { id: number, updateFacultyDto: UpdateFacultyDto, ): Promise { - const { courseIds, ...facultyData } = updateFacultyDto; + const { courseIds } = updateFacultyDto; const faculty = await this.facultyRepository.findOneByOrFail({ id }); - this.facultyRepository.merge(faculty, facultyData); - if (courseIds !== undefined) { faculty.courses = await validateAndGetRelations( this.courseRepository, diff --git a/src/i18n/entities/course-translation.entity.ts b/src/i18n/entities/course-translation.entity.ts new file mode 100644 index 00000000..0602ccb5 --- /dev/null +++ b/src/i18n/entities/course-translation.entity.ts @@ -0,0 +1,46 @@ +import { + Column, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from "typeorm"; +import { Course } from "@/courses/entities/course.entity"; + +@Entity() +export class CourseTranslation { + @PrimaryGeneratedColumn() + id: number; + + @Column({ name: "course_id" }) + courseId: number; + + /** + * The language code for this translation. + * @example "en" + */ + @Column({ name: "language_code", length: 10 }) + languageCode: string; + + /** + * The course name in this language. + * @example "Computer Science" + */ + @Column() + name: string; + + /** + * The course acronym in this language. + * @example "CS" + */ + @Column({ length: 20 }) + acronym: string; + + @ManyToOne( + () => Course, + (course) => course.translations, + { onDelete: "CASCADE" }, + ) + @JoinColumn({ name: "course_id" }) + course: Course; +} diff --git a/src/i18n/entities/event-translation.entity.ts b/src/i18n/entities/event-translation.entity.ts new file mode 100644 index 00000000..f9145031 --- /dev/null +++ b/src/i18n/entities/event-translation.entity.ts @@ -0,0 +1,46 @@ +import { + Column, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from "typeorm"; +import { Event } from "@/events/entities/event.entity"; + +@Entity() +export class EventTranslation { + @PrimaryGeneratedColumn() + id: number; + + @Column({ name: "event_id" }) + eventId: number; + + /** + * The language code for this translation. + * @example "en" + */ + @Column({ name: "language_code", length: 10 }) + languageCode: string; + + /** + * The event name in this language. + * @example "FEUP Week" + */ + @Column() + name: string; + + /** + * The event description in this language. + * @example "FEUP week is a period of interruption of classes and teaching mobility." + */ + @Column({ type: "text", nullable: true }) + description: string | null; + + @ManyToOne( + () => Event, + (event) => event.translations, + { onDelete: "CASCADE" }, + ) + @JoinColumn({ name: "event_id" }) + event: Event; +} diff --git a/src/i18n/entities/faculty-translation.entity.ts b/src/i18n/entities/faculty-translation.entity.ts new file mode 100644 index 00000000..907ae418 --- /dev/null +++ b/src/i18n/entities/faculty-translation.entity.ts @@ -0,0 +1,46 @@ +import { + Column, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from "typeorm"; +import { Faculty } from "@/faculties/entities/faculty.entity"; + +@Entity() +export class FacultyTranslation { + @PrimaryGeneratedColumn() + id: number; + + @Column({ name: "faculty_id" }) + facultyId: number; + + /** + * The language code for this translation. + * @example "en" + */ + @Column({ name: "language_code", length: 10 }) + languageCode: string; + + /** + * The faculty name in this language. + * @example "Faculty of Engineering" + */ + @Column() + name: string; + + /** + * The faculty acronym in this language. + * @example "FEUP" + */ + @Column({ length: 20 }) + acronym: string; + + @ManyToOne( + () => Faculty, + (faculty) => faculty.translations, + { onDelete: "CASCADE" }, + ) + @JoinColumn({ name: "faculty_id" }) + faculty: Faculty; +} diff --git a/src/i18n/entities/index.ts b/src/i18n/entities/index.ts new file mode 100644 index 00000000..b969b0e1 --- /dev/null +++ b/src/i18n/entities/index.ts @@ -0,0 +1,3 @@ +export * from "./course-translation.entity"; +export * from "./event-translation.entity"; +export * from "./faculty-translation.entity"; diff --git a/src/i18n/i18n.module.ts b/src/i18n/i18n.module.ts new file mode 100644 index 00000000..e66f15d1 --- /dev/null +++ b/src/i18n/i18n.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from "@nestjs/common"; +import { I18nService } from "./i18n.service"; + +@Global() +@Module({ + providers: [I18nService], + exports: [I18nService], +}) +export class I18nModule {} diff --git a/src/i18n/i18n.service.spec.ts b/src/i18n/i18n.service.spec.ts new file mode 100644 index 00000000..2ad17e5c --- /dev/null +++ b/src/i18n/i18n.service.spec.ts @@ -0,0 +1,37 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { I18nService } from "./i18n.service"; + +describe("I18nService", () => { + let service: I18nService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [I18nService], + }).compile(); + + service = module.get(I18nService); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("getLanguage", () => { + it("should return default language 'en'", () => { + expect(service.getLanguage()).toBe("en"); + }); + }); + + describe("setLanguage", () => { + it("should set the language", () => { + service.setLanguage("pt"); + expect(service.getLanguage()).toBe("pt"); + }); + + it("should allow changing language multiple times", () => { + service.setLanguage("pt"); + service.setLanguage("en"); + expect(service.getLanguage()).toBe("en"); + }); + }); +}); diff --git a/src/i18n/i18n.service.ts b/src/i18n/i18n.service.ts new file mode 100644 index 00000000..88732163 --- /dev/null +++ b/src/i18n/i18n.service.ts @@ -0,0 +1,14 @@ +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class I18nService { + private language: string = "en"; + + setLanguage(language: string): void { + this.language = language; + } + + getLanguage(): string { + return this.language; + } +} diff --git a/src/main.spec.ts b/src/main.spec.ts index 730e7192..f29f20d3 100644 --- a/src/main.spec.ts +++ b/src/main.spec.ts @@ -13,6 +13,7 @@ const mockDocumentBuilderInstance = { setDescription: jest.fn().mockReturnThis(), addServer: jest.fn().mockReturnThis(), addBearerAuth: jest.fn().mockReturnThis(), + addGlobalParameters: jest.fn().mockReturnThis(), build: jest.fn().mockReturnValue({}), }; @@ -23,6 +24,7 @@ const mockSwaggerModule = { jest.mock("@nestjs/core", () => ({ NestFactory: mockNestFactory, + APP_INTERCEPTOR: "APP_INTERCEPTOR", })); jest.mock("@nestjs/swagger", () => ({ @@ -38,11 +40,15 @@ jest.mock("./filters/entity-not-found.filter", () => ({ EntityNotFoundFilter: class MockFilter {}, })); +jest.mock("./common/interceptors/translation.interceptor", () => ({ + TranslationInterceptor: class MockInterceptor {}, +})); + jest.mock("@nestjs/common", () => { const actual = jest.requireActual("@nestjs/common"); return { ...actual, - ValidationPipe: jest.fn(), + ValidationPipe: class MockValidationPipe {}, }; }); diff --git a/src/main.ts b/src/main.ts index 8cd490d0..f4a5e8ee 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,6 +18,15 @@ async function bootstrap() { { type: "http", scheme: "bearer", bearerFormat: "JWT" }, "access-token", ) + .addGlobalParameters({ + name: "accept-language", + in: "header", + description: "The language of the response", + schema: { + default: "en", + enum: ["en", "pt"], + }, + }) .build(); const documentFactory = () => SwaggerModule.createDocument(app, config); SwaggerModule.setup("docs", app, documentFactory); diff --git a/src/services/entity/service.entity.spec.ts b/src/services/entity/service.entity.spec.ts index 05efa606..7acab52b 100644 --- a/src/services/entity/service.entity.spec.ts +++ b/src/services/entity/service.entity.spec.ts @@ -8,16 +8,34 @@ describe("Service Entity", () => { const mockFaculty: Faculty = { id: 1, - name: "Engineering Faculty", - acronym: "FEUP", + defaultLanguage: "en", + translations: [ + { + id: 1, + facultyId: 1, + languageCode: "en", + name: "Engineering Faculty", + acronym: "FEUP", + faculty: undefined as any, + }, + ], events: [], courses: [], }; const mockCourse: Course = { id: 1, - name: "Computer Science", - acronym: "CS", + defaultLanguage: "en", + translations: [ + { + id: 1, + courseId: 1, + languageCode: "en", + name: "Computer Science", + acronym: "CS", + course: undefined as any, + }, + ], faculties: [], events: [], }; diff --git a/src/services/services.service.spec.ts b/src/services/services.service.spec.ts index ebf35785..5b53f4c6 100644 --- a/src/services/services.service.spec.ts +++ b/src/services/services.service.spec.ts @@ -14,16 +14,34 @@ describe("ServicesService", () => { const mockFaculty: Faculty = { id: 1, - name: "Engineering Faculty", - acronym: "FEUP", + defaultLanguage: "en", + translations: [ + { + id: 1, + facultyId: 1, + languageCode: "en", + name: "Engineering Faculty", + acronym: "FEUP", + faculty: undefined as any, + }, + ], events: [], courses: [], }; const mockCourse: Course = { id: 1, - name: "Computer Science", - acronym: "CS", + defaultLanguage: "en", + translations: [ + { + id: 1, + courseId: 1, + languageCode: "en", + name: "Computer Science", + acronym: "CS", + course: undefined as any, + }, + ], faculties: [], events: [], };