diff --git a/cozero-backend/package-lock.json b/cozero-backend/package-lock.json index b885e76..aae7372 100644 --- a/cozero-backend/package-lock.json +++ b/cozero-backend/package-lock.json @@ -18,6 +18,8 @@ "@nestjs/platform-express": "^9.0.0", "@nestjs/typeorm": "^9.0.0", "bcrypt": "^5.0.1", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.0", "mysql2": "^2.3.3", "passport": "^0.6.0", "passport-jwt": "^4.0.0", @@ -794,7 +796,7 @@ "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "devOptional": true, + "dev": true, "dependencies": { "@jridgewell/trace-mapping": "0.3.9" }, @@ -806,7 +808,7 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "devOptional": true, + "dev": true, "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" @@ -1351,7 +1353,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", - "devOptional": true, + "dev": true, "engines": { "node": ">=6.0.0" } @@ -1393,7 +1395,7 @@ "version": "1.4.14", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", - "devOptional": true + "dev": true }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.14", @@ -1797,25 +1799,25 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", - "devOptional": true + "dev": true }, "node_modules/@tsconfig/node12": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "devOptional": true + "dev": true }, "node_modules/@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "devOptional": true + "dev": true }, "node_modules/@tsconfig/node16": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz", "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==", - "devOptional": true + "dev": true }, "node_modules/@types/babel__core": { "version": "7.1.19", @@ -2104,6 +2106,11 @@ "@types/superagent": "*" } }, + "node_modules/@types/validator": { + "version": "13.7.17", + "resolved": "https://npm-registry.evooq.ch/@types/validator/-/validator-13.7.17.tgz", + "integrity": "sha512-aqayTNmeWrZcvnG2MG9eGYI6b7S5fl+yKgPs6bAjOTwPS316R5SxBGKvtSExfyoJU7pIeHJfsHI0Ji41RVMkvQ==" + }, "node_modules/@types/yargs": { "version": "17.0.10", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.10.tgz", @@ -2482,7 +2489,7 @@ "version": "8.7.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.1.tgz", "integrity": "sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==", - "devOptional": true, + "dev": true, "bin": { "acorn": "bin/acorn" }, @@ -2512,7 +2519,7 @@ "version": "8.2.0", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", - "devOptional": true, + "dev": true, "engines": { "node": ">=0.4.0" } @@ -2684,7 +2691,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "devOptional": true + "dev": true }, "node_modules/argparse": { "version": "2.0.1", @@ -3184,6 +3191,21 @@ "integrity": "sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==", "dev": true }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://npm-registry.evooq.ch/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==" + }, + "node_modules/class-validator": { + "version": "0.14.0", + "resolved": "https://npm-registry.evooq.ch/class-validator/-/class-validator-0.14.0.tgz", + "integrity": "sha512-ct3ltplN8I9fOwUd8GrP8UQixwff129BkEtuWDKL5W45cQuLd19xqmTLu5ge78YDm/fdje6FMt0hGOhl0lii3A==", + "dependencies": { + "@types/validator": "^13.7.10", + "libphonenumber-js": "^1.10.14", + "validator": "^13.7.0" + } + }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -3497,7 +3519,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "devOptional": true + "dev": true }, "node_modules/cross-spawn": { "version": "7.0.3", @@ -3641,7 +3663,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "devOptional": true, + "dev": true, "engines": { "node": ">=0.3.1" } @@ -6150,6 +6172,11 @@ "node": ">= 0.8.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.10.37", + "resolved": "https://npm-registry.evooq.ch/libphonenumber-js/-/libphonenumber-js-1.10.37.tgz", + "integrity": "sha512-Z10PCaOCiAxbUxLyR31DNeeNugSVP6iv/m7UrSKS5JHziEMApJtgku4e9Q69pzzSC9LnQiM09sqsGf2ticZnMw==" + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -6327,7 +6354,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "devOptional": true + "dev": true }, "node_modules/makeerror": { "version": "1.0.12", @@ -8327,7 +8354,7 @@ "version": "10.8.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.8.2.tgz", "integrity": "sha512-LYdGnoGddf1D6v8REPtIH+5iq/gTDuZqv2/UJUU7tKjuEU8xVZorBM+buCGNjj+pGEud+sOoM4CX3/YzINpENA==", - "devOptional": true, + "dev": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -8682,7 +8709,7 @@ "version": "4.7.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz", "integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==", - "devOptional": true, + "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8774,7 +8801,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "devOptional": true + "dev": true }, "node_modules/v8-to-istanbul": { "version": "9.0.1", @@ -8790,6 +8817,14 @@ "node": ">=10.12.0" } }, + "node_modules/validator": { + "version": "13.9.0", + "resolved": "https://npm-registry.evooq.ch/validator/-/validator-13.9.0.tgz", + "integrity": "sha512-B+dGG8U3fdtM0/aNK4/X8CXq/EcxU2WPrPEkJGslb47qyHsxmbggTWK0yEA4qnYVNF+nxNlN88o14hIcPmSIEA==", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -9115,7 +9150,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "devOptional": true, + "dev": true, "engines": { "node": ">=6" } @@ -9684,7 +9719,7 @@ "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "devOptional": true, + "dev": true, "requires": { "@jridgewell/trace-mapping": "0.3.9" }, @@ -9693,7 +9728,7 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "devOptional": true, + "dev": true, "requires": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" @@ -10126,7 +10161,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", - "devOptional": true + "dev": true }, "@jridgewell/set-array": { "version": "1.1.2", @@ -10161,7 +10196,7 @@ "version": "1.4.14", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", - "devOptional": true + "dev": true }, "@jridgewell/trace-mapping": { "version": "0.3.14", @@ -10295,14 +10330,12 @@ "@nestjs/mapped-types": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-1.1.0.tgz", - "integrity": "sha512-+2kSly4P1QI+9eGt+/uGyPdEG1hVz7nbpqPHWZVYgoqz8eOHljpXPag+UCVRw9zo2XCu4sgNUIGe8Uk0+OvUQg==", - "requires": {} + "integrity": "sha512-+2kSly4P1QI+9eGt+/uGyPdEG1hVz7nbpqPHWZVYgoqz8eOHljpXPag+UCVRw9zo2XCu4sgNUIGe8Uk0+OvUQg==" }, "@nestjs/passport": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-9.0.0.tgz", - "integrity": "sha512-Gnh8n1wzFPOLSS/94X1sUP4IRAoXTgG4odl7/AO5h+uwscEGXxJFercrZfqdAwkWhqkKWbsntM3j5mRy/6ZQDA==", - "requires": {} + "integrity": "sha512-Gnh8n1wzFPOLSS/94X1sUP4IRAoXTgG4odl7/AO5h+uwscEGXxJFercrZfqdAwkWhqkKWbsntM3j5mRy/6ZQDA==" }, "@nestjs/platform-express": { "version": "9.0.2", @@ -10426,25 +10459,25 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", - "devOptional": true + "dev": true }, "@tsconfig/node12": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "devOptional": true + "dev": true }, "@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "devOptional": true + "dev": true }, "@tsconfig/node16": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz", "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==", - "devOptional": true + "dev": true }, "@types/babel__core": { "version": "7.1.19", @@ -10733,6 +10766,11 @@ "@types/superagent": "*" } }, + "@types/validator": { + "version": "13.7.17", + "resolved": "https://npm-registry.evooq.ch/@types/validator/-/validator-13.7.17.tgz", + "integrity": "sha512-aqayTNmeWrZcvnG2MG9eGYI6b7S5fl+yKgPs6bAjOTwPS316R5SxBGKvtSExfyoJU7pIeHJfsHI0Ji41RVMkvQ==" + }, "@types/yargs": { "version": "17.0.10", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.10.tgz", @@ -11019,27 +11057,25 @@ "version": "8.7.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.1.tgz", "integrity": "sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==", - "devOptional": true + "dev": true }, "acorn-import-assertions": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz", "integrity": "sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw==", - "dev": true, - "requires": {} + "dev": true }, "acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "requires": {} + "dev": true }, "acorn-walk": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", - "devOptional": true + "dev": true }, "agent-base": { "version": "6.0.2", @@ -11161,7 +11197,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "devOptional": true + "dev": true }, "argparse": { "version": "2.0.1", @@ -11525,6 +11561,21 @@ "integrity": "sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==", "dev": true }, + "class-transformer": { + "version": "0.5.1", + "resolved": "https://npm-registry.evooq.ch/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==" + }, + "class-validator": { + "version": "0.14.0", + "resolved": "https://npm-registry.evooq.ch/class-validator/-/class-validator-0.14.0.tgz", + "integrity": "sha512-ct3ltplN8I9fOwUd8GrP8UQixwff129BkEtuWDKL5W45cQuLd19xqmTLu5ge78YDm/fdje6FMt0hGOhl0lii3A==", + "requires": { + "@types/validator": "^13.7.10", + "libphonenumber-js": "^1.10.14", + "validator": "^13.7.0" + } + }, "cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -11769,7 +11820,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "devOptional": true + "dev": true }, "cross-spawn": { "version": "7.0.3", @@ -11873,7 +11924,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "devOptional": true + "dev": true }, "diff-sequences": { "version": "28.1.1", @@ -12096,8 +12147,7 @@ "version": "8.5.0", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.5.0.tgz", "integrity": "sha512-obmWKLUNCnhtQRKc+tmnYuQl0pFU1ibYJQ5BGhTVB08bHe9wC8qUeG7c08dj9XX+AuPj1YSGSQIHl1pnDHZR0Q==", - "dev": true, - "requires": {} + "dev": true }, "eslint-plugin-prettier": { "version": "4.2.1", @@ -13349,8 +13399,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz", "integrity": "sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w==", - "dev": true, - "requires": {} + "dev": true }, "jest-regex-util": { "version": "28.0.2", @@ -13772,6 +13821,11 @@ "type-check": "~0.4.0" } }, + "libphonenumber-js": { + "version": "1.10.37", + "resolved": "https://npm-registry.evooq.ch/libphonenumber-js/-/libphonenumber-js-1.10.37.tgz", + "integrity": "sha512-Z10PCaOCiAxbUxLyR31DNeeNugSVP6iv/m7UrSKS5JHziEMApJtgku4e9Q69pzzSC9LnQiM09sqsGf2ticZnMw==" + }, "lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -13914,7 +13968,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "devOptional": true + "dev": true }, "makeerror": { "version": "1.0.12", @@ -14802,8 +14856,7 @@ "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "requires": {} + "dev": true }, "json-schema-traverse": { "version": "0.4.1", @@ -15393,7 +15446,7 @@ "version": "10.8.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.8.2.tgz", "integrity": "sha512-LYdGnoGddf1D6v8REPtIH+5iq/gTDuZqv2/UJUU7tKjuEU8xVZorBM+buCGNjj+pGEud+sOoM4CX3/YzINpENA==", - "devOptional": true, + "dev": true, "requires": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -15589,7 +15642,7 @@ "version": "4.7.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz", "integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==", - "devOptional": true + "dev": true }, "universalify": { "version": "2.0.0", @@ -15646,7 +15699,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "devOptional": true + "dev": true }, "v8-to-istanbul": { "version": "9.0.1", @@ -15659,6 +15712,11 @@ "convert-source-map": "^1.6.0" } }, + "validator": { + "version": "13.9.0", + "resolved": "https://npm-registry.evooq.ch/validator/-/validator-13.9.0.tgz", + "integrity": "sha512-B+dGG8U3fdtM0/aNK4/X8CXq/EcxU2WPrPEkJGslb47qyHsxmbggTWK0yEA4qnYVNF+nxNlN88o14hIcPmSIEA==" + }, "vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -15899,7 +15957,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "devOptional": true + "dev": true } } } diff --git a/cozero-backend/package.json b/cozero-backend/package.json index 2712e31..c703e4a 100644 --- a/cozero-backend/package.json +++ b/cozero-backend/package.json @@ -30,6 +30,8 @@ "@nestjs/platform-express": "^9.0.0", "@nestjs/typeorm": "^9.0.0", "bcrypt": "^5.0.1", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.0", "mysql2": "^2.3.3", "passport": "^0.6.0", "passport-jwt": "^4.0.0", diff --git a/cozero-backend/src/app.controller.spec.ts b/cozero-backend/src/app.controller.spec.ts index d22f389..0f4f476 100644 --- a/cozero-backend/src/app.controller.spec.ts +++ b/cozero-backend/src/app.controller.spec.ts @@ -1,6 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AppController } from './app.controller'; -import { AppService } from './app.service'; +import { AuthService } from './auth/auth.service'; describe('AppController', () => { let appController: AppController; @@ -8,15 +8,20 @@ describe('AppController', () => { beforeEach(async () => { const app: TestingModule = await Test.createTestingModule({ controllers: [AppController], - providers: [AppService], + providers: [ + { + provide: AuthService, + useValue: { + login: jest.fn(), + }, + }, + ], }).compile(); appController = app.get(AppController); }); - describe('root', () => { - it('should return "Hello World!"', () => { - expect(appController.getHello()).toBe('Hello World!'); - }); + it('should be defined', () => { + expect(appController).toBeDefined(); }); }); diff --git a/cozero-backend/src/app.controller.ts b/cozero-backend/src/app.controller.ts index 6ac3b46..d12c50e 100644 --- a/cozero-backend/src/app.controller.ts +++ b/cozero-backend/src/app.controller.ts @@ -1,11 +1,16 @@ -import { Controller, Get, Post, UseGuards, Request, Req } from '@nestjs/common'; -import { AppService } from './app.service'; +import { Controller, Post, UseGuards, Request, Get } from '@nestjs/common'; import { AuthService } from './auth/auth.service'; import { LocalAuthGuard } from './auth/local-auth.guard'; import { SkipAuth } from './decorators/skipAuth.decorator'; +import { AppService } from './app.service'; @Controller() export class AppController { + constructor( + private appService: AppService, + private authService: AuthService, + ) {} + @SkipAuth() @UseGuards(LocalAuthGuard) @Post('auth/login') @@ -13,14 +18,15 @@ export class AppController { return this.authService.login(req.user); } - constructor( - private readonly appService: AppService, - private authService: AuthService, - ) {} + @SkipAuth() + @Get('health') + async healthCheck() { + return 'OK'; + } @SkipAuth() - @Get() - getHello(@Request() req): string { - return this.appService.getHello(); + @Get('health-db') + async healthDBCheck() { + return this.appService.healthDBCheck(); } } diff --git a/cozero-backend/src/app.service.ts b/cozero-backend/src/app.service.ts index 927d7cc..ba04f8b 100644 --- a/cozero-backend/src/app.service.ts +++ b/cozero-backend/src/app.service.ts @@ -1,8 +1,12 @@ import { Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; @Injectable() export class AppService { - getHello(): string { - return 'Hello World!'; + constructor(@InjectDataSource() private dataSource: DataSource) {} + + async healthDBCheck() { + await this.dataSource.query('SELECT 1=1'); } } diff --git a/cozero-backend/src/auth/auth.controller.ts b/cozero-backend/src/auth/auth.controller.ts index 7055fe0..7376c08 100644 --- a/cozero-backend/src/auth/auth.controller.ts +++ b/cozero-backend/src/auth/auth.controller.ts @@ -1,6 +1,6 @@ import { Controller, Post, Body } from '@nestjs/common'; -import { SkipAuth } from 'src/decorators/skipAuth.decorator'; -import { UserLoginDto } from 'src/users/dto/user-login.dto'; +import { SkipAuth } from '../decorators/skipAuth.decorator'; +import { UserLoginDto } from '../users/dto/user-login.dto'; import { AuthService } from './auth.service'; @Controller('auth') diff --git a/cozero-backend/src/auth/auth.module.ts b/cozero-backend/src/auth/auth.module.ts index 086da6e..2e7427a 100644 --- a/cozero-backend/src/auth/auth.module.ts +++ b/cozero-backend/src/auth/auth.module.ts @@ -4,9 +4,9 @@ import { PassportModule } from '@nestjs/passport'; import { LocalStrategy } from './local.strategy'; import { JwtModule } from '@nestjs/jwt'; import { JwtStrategy } from './jwt.strategy'; -import { UsersService } from 'src/users/users.service'; +import { UsersService } from '../users/users.service'; import { UsersModule } from '../users/users.module'; -import { User } from 'src/users/entities/user.entity'; +import { User } from '../users/entities/user.entity'; import { TypeOrmModule } from '@nestjs/typeorm'; import * as dotenv from 'dotenv'; import { AuthController } from './auth.controller'; diff --git a/cozero-backend/src/auth/auth.service.spec.ts b/cozero-backend/src/auth/auth.service.spec.ts index 800ab66..060a15e 100644 --- a/cozero-backend/src/auth/auth.service.spec.ts +++ b/cozero-backend/src/auth/auth.service.spec.ts @@ -1,15 +1,36 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { JwtService } from '@nestjs/jwt'; import { AuthService } from './auth.service'; +import { UsersService } from '../users/users.service'; describe('AuthService', () => { let service: AuthService; + let jwtServiceMock: any; + let usersServiceMock: any; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [AuthService], + providers: [ + AuthService, + { + provide: JwtService, + useValue: { + sign: jest.fn(), + }, + }, + { + provide: UsersService, + useValue: { + findOne: jest.fn(), + create: jest.fn(), + }, + }, + ], }).compile(); service = module.get(AuthService); + jwtServiceMock = module.get(JwtService); + usersServiceMock = module.get(UsersService); }); it('should be defined', () => { diff --git a/cozero-backend/src/auth/auth.service.ts b/cozero-backend/src/auth/auth.service.ts index 1f3701e..df69e5e 100644 --- a/cozero-backend/src/auth/auth.service.ts +++ b/cozero-backend/src/auth/auth.service.ts @@ -1,7 +1,7 @@ import { forwardRef, Inject, Injectable, UseGuards } from '@nestjs/common'; import { UsersService } from '../users/users.service'; import { JwtService } from '@nestjs/jwt'; -import { UserLoginDto } from 'src/users/dto/user-login.dto'; +import { UserLoginDto } from '../users/dto/user-login.dto'; import * as bcrypt from 'bcrypt'; import { LocalAuthGuard } from './local-auth.guard'; diff --git a/cozero-backend/src/auth/jwt-auth.guard.ts b/cozero-backend/src/auth/jwt-auth.guard.ts index f2ec975..3b9b5f7 100644 --- a/cozero-backend/src/auth/jwt-auth.guard.ts +++ b/cozero-backend/src/auth/jwt-auth.guard.ts @@ -1,7 +1,7 @@ import { ExecutionContext, Injectable } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; -import { IS_PUBLIC_KEY } from 'src/decorators/skipAuth.decorator'; +import { IS_PUBLIC_KEY } from '../decorators/skipAuth.decorator'; @Injectable() export class JwtAuthGuard extends AuthGuard('jwt') { @@ -20,4 +20,3 @@ export class JwtAuthGuard extends AuthGuard('jwt') { return super.canActivate(context); } } - diff --git a/cozero-backend/src/auth/jwt.strategy.ts b/cozero-backend/src/auth/jwt.strategy.ts index cdc9c0d..46dbe44 100644 --- a/cozero-backend/src/auth/jwt.strategy.ts +++ b/cozero-backend/src/auth/jwt.strategy.ts @@ -21,7 +21,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { }); } - async validate(req: e.Request, payload: JwtPayload) { - return { userId: payload.sub, email: payload.email }; + async validate(payload: JwtPayload) { + return { email: payload.email }; } } diff --git a/cozero-backend/src/exceptions/duplicated-email.exception.ts b/cozero-backend/src/exceptions/duplicated-email.exception.ts new file mode 100644 index 0000000..83e0564 --- /dev/null +++ b/cozero-backend/src/exceptions/duplicated-email.exception.ts @@ -0,0 +1,7 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +export class DuplicateEmailException extends HttpException { + constructor() { + super('Email already exists', HttpStatus.BAD_REQUEST); + } +} diff --git a/cozero-backend/src/exceptions/not-owner.exception.ts b/cozero-backend/src/exceptions/not-owner.exception.ts new file mode 100644 index 0000000..264a634 --- /dev/null +++ b/cozero-backend/src/exceptions/not-owner.exception.ts @@ -0,0 +1,7 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +export class NotOwnerException extends HttpException { + constructor() { + super('This user is not the owner', HttpStatus.BAD_REQUEST); + } +} diff --git a/cozero-backend/src/main.ts b/cozero-backend/src/main.ts index 0e07a1b..bd7ada9 100644 --- a/cozero-backend/src/main.ts +++ b/cozero-backend/src/main.ts @@ -1,10 +1,12 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; +import { ValidationPipe } from '@nestjs/common'; declare const module: any; async function bootstrap() { const app = await NestFactory.create(AppModule, { cors: true }); + app.useGlobalPipes(new ValidationPipe()); await app.listen(3001); if (module.hot) { diff --git a/cozero-backend/src/projects/dto/create-project.dto.ts b/cozero-backend/src/projects/dto/create-project.dto.ts index 008192a..94d71ca 100644 --- a/cozero-backend/src/projects/dto/create-project.dto.ts +++ b/cozero-backend/src/projects/dto/create-project.dto.ts @@ -1,7 +1,15 @@ +import { ArrayNotEmpty, IsArray, IsOptional, IsString } from 'class-validator'; + export class CreateProjectDto { + @IsString() name: string; + @IsString() description: string; + @IsOptional() + @IsArray() co2EstimateReduction: number[]; + @IsString() owner: string; + @ArrayNotEmpty() listing: string[]; } diff --git a/cozero-backend/src/projects/dto/page-metadata.dto.ts b/cozero-backend/src/projects/dto/page-metadata.dto.ts new file mode 100644 index 0000000..bf02826 --- /dev/null +++ b/cozero-backend/src/projects/dto/page-metadata.dto.ts @@ -0,0 +1,12 @@ +import { IsNumber } from 'class-validator'; + +export class PageMetadataDto { + @IsNumber() + page: number; + @IsNumber() + perPage: number; + @IsNumber() + total: number; + @IsNumber() + totalPages: number; +} diff --git a/cozero-backend/src/projects/dto/paginated-projects.dto.ts b/cozero-backend/src/projects/dto/paginated-projects.dto.ts new file mode 100644 index 0000000..a1c6f10 --- /dev/null +++ b/cozero-backend/src/projects/dto/paginated-projects.dto.ts @@ -0,0 +1,15 @@ +import { IsArray, IsNumber } from 'class-validator'; +import { Project } from '../entities/project.entity'; + +export class PaginatedProjectsDto { + @IsArray() + data: Project[]; + @IsNumber() + page: number; + @IsNumber() + perPage: number; + @IsNumber() + totalItems: number; + @IsNumber() + totalPages: number; +} diff --git a/cozero-backend/src/projects/entities/project.entity.ts b/cozero-backend/src/projects/entities/project.entity.ts index f27c4bf..0726892 100644 --- a/cozero-backend/src/projects/entities/project.entity.ts +++ b/cozero-backend/src/projects/entities/project.entity.ts @@ -4,6 +4,7 @@ import { PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, + DeleteDateColumn, } from 'typeorm'; @Entity() @@ -34,4 +35,7 @@ export class Project { @UpdateDateColumn() updatedAt: string; + + @DeleteDateColumn() + deletedAt: string; } diff --git a/cozero-backend/src/projects/projects.controller.spec.ts b/cozero-backend/src/projects/projects.controller.spec.ts index fc37f96..3acea80 100644 --- a/cozero-backend/src/projects/projects.controller.spec.ts +++ b/cozero-backend/src/projects/projects.controller.spec.ts @@ -1,20 +1,138 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ProjectsController } from './projects.controller'; import { ProjectsService } from './projects.service'; +import { User } from '../users/entities/user.entity'; +import { UpdateProjectDto } from './dto/update-project.dto'; +import { CreateProjectDto } from './dto/create-project.dto'; +import { UnauthorizedException } from '@nestjs/common'; + +const projectDto: CreateProjectDto = { + name: 'Test Project', + description: 'description', + co2EstimateReduction: [0, 1], + owner: 'email@email.com', + listing: ['1'], +}; describe('ProjectsController', () => { let controller: ProjectsController; + let serviceMock: any; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [ProjectsController], - providers: [ProjectsService], + providers: [ + ProjectsService, + { + provide: ProjectsService, + useValue: { + create: jest.fn(), + findAll: jest.fn(), + findSoftDeleted: jest.fn(), + findOne: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + restore: jest.fn(), + }, + }, + ], }).compile(); controller = module.get(ProjectsController); + serviceMock = module.get(ProjectsService); }); it('should be defined', () => { expect(controller).toBeDefined(); }); + + it('should create a project', async () => { + await controller.create(projectDto); + + expect(serviceMock.create).toHaveBeenCalledWith(projectDto); + }); + + it('should not create a project if the token is not valid', async () => { + jest + .spyOn(serviceMock, 'create') + .mockRejectedValue(new UnauthorizedException()); + + await expect(controller.create(projectDto)).rejects.toBeInstanceOf( + UnauthorizedException, + ); + + expect(serviceMock.create).toHaveBeenCalledWith(projectDto); + }); + + it('should return all projects', async () => { + const page = 1; + const limit = 10; + const searchTerm = ''; + + await controller.findAll(page, limit, searchTerm); + + expect(serviceMock.findAll).toHaveBeenCalledWith(page, limit, searchTerm); + }); + + it('should return soft deleted projects', async () => { + const page = 1; + const limit = 10; + const request: any = { + user: { email: 'email@email.com' } as User, + }; + + await controller.findSoftDeleted(page, limit, request); + + expect(serviceMock.findSoftDeleted).toHaveBeenCalledWith( + page, + limit, + request.user.email, + ); + }); + + it('should return a specific project', async () => { + const id = '1'; + + await controller.findOne(id); + + expect(serviceMock.findOne).toHaveBeenCalledWith(+id); + }); + + it('should update a specific project', async () => { + const id = '1'; + const updateProjectDto: UpdateProjectDto = { name: 'Updated Project' }; + const request: any = { + user: { email: 'email@email.com' } as User, + }; + + await controller.update(id, updateProjectDto, request); + + expect(serviceMock.update).toHaveBeenCalledWith( + +id, + updateProjectDto, + request.user.email, + ); + }); + + it('should remove a specific project', async () => { + const id = '1'; + const request: any = { + user: { email: 'email@email.com' } as User, + }; + + await controller.remove(id, request); + + expect(serviceMock.remove).toHaveBeenCalledWith(+id, request.user.email); + }); + + it('should restore a specific project', async () => { + const id = '1'; + const request: any = { + user: { email: 'email@email.com' } as User, + }; + + await controller.restore(id, request); + + expect(serviceMock.restore).toHaveBeenCalledWith(+id, request.user.email); + }); }); diff --git a/cozero-backend/src/projects/projects.controller.ts b/cozero-backend/src/projects/projects.controller.ts index 5548b4e..3a7f258 100644 --- a/cozero-backend/src/projects/projects.controller.ts +++ b/cozero-backend/src/projects/projects.controller.ts @@ -6,11 +6,18 @@ import { Param, Delete, Put, + Query, + Patch, + UseGuards, + Req, } from '@nestjs/common'; +import { Request } from 'express'; import { ProjectsService } from './projects.service'; import { CreateProjectDto } from './dto/create-project.dto'; import { UpdateProjectDto } from './dto/update-project.dto'; -import { SkipAuth } from 'src/decorators/skipAuth.decorator'; +import { SkipAuth } from '../decorators/skipAuth.decorator'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { User } from '../users/entities/user.entity'; @Controller('projects') export class ProjectsController { @@ -23,8 +30,26 @@ export class ProjectsController { @SkipAuth() @Get() - findAll() { - return this.projectsService.findAll(); + findAll( + @Query('page') page: number = 1, + @Query('limit') limit: number = 10, + @Query('searchTerm') searchTerm: string = '', + ) { + return this.projectsService.findAll(page, limit, searchTerm); + } + + @UseGuards(JwtAuthGuard) + @Get('deleted') + findSoftDeleted( + @Query('page') page: number = 1, + @Query('limit') limit: number = 10, + @Req() request: Request & { user: User }, + ) { + return this.projectsService.findSoftDeleted( + page, + limit, + request.user.email, + ); } @SkipAuth() @@ -33,13 +58,29 @@ export class ProjectsController { return this.projectsService.findOne(+id); } + @UseGuards(JwtAuthGuard) @Put(':id') - update(@Param('id') id: string, @Body() updateProjectDto: UpdateProjectDto) { - return this.projectsService.update(+id, updateProjectDto); + update( + @Param('id') id: string, + @Body() updateProjectDto: UpdateProjectDto, + @Req() request: Request & { user: User }, + ) { + return this.projectsService.update( + +id, + updateProjectDto, + request.user.email, + ); } + @UseGuards(JwtAuthGuard) @Delete(':id') - remove(@Param('id') id: string) { - return this.projectsService.remove(+id); + remove(@Param('id') id: string, @Req() request: Request & { user: User }) { + return this.projectsService.remove(+id, request.user.email); + } + + @UseGuards(JwtAuthGuard) + @Patch(':id') + restore(@Param('id') id: string, @Req() request: Request & { user: User }) { + return this.projectsService.restore(+id, request.user.email); } } diff --git a/cozero-backend/src/projects/projects.service.spec.ts b/cozero-backend/src/projects/projects.service.spec.ts index d3b3101..e65bfff 100644 --- a/cozero-backend/src/projects/projects.service.spec.ts +++ b/cozero-backend/src/projects/projects.service.spec.ts @@ -1,18 +1,229 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { ProjectsService } from './projects.service'; +import { NotOwnerException, ProjectsService } from './projects.service'; +import { Project } from './entities/project.entity'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { CreateProjectDto } from './dto/create-project.dto'; + +const projectDto: CreateProjectDto = { + name: 'Test Project', + description: 'description', + co2EstimateReduction: [0, 1], + owner: 'email@email.com', + listing: ['1'], +}; describe('ProjectsService', () => { let service: ProjectsService; + let repositoryMock: any; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [ProjectsService], + providers: [ + ProjectsService, + { + provide: getRepositoryToken(Project), + useValue: { + findAndCount: jest.fn(), + save: jest.fn(), + update: jest.fn(), + findOneBy: jest.fn(), + findOne: jest.fn(), + restore: jest.fn(), + softDelete: jest.fn(), + }, + }, + ], }).compile(); service = module.get(ProjectsService); + repositoryMock = module.get(getRepositoryToken(Project)); }); it('should be defined', () => { expect(service).toBeDefined(); }); + + it('should create a project', async () => { + const createdProject = { id: 1, name: 'Test Project' }; + + repositoryMock.save.mockResolvedValue(createdProject); + + const result = await service.create(projectDto); + + expect(repositoryMock.save).toHaveBeenCalledWith(projectDto); + expect(result).toEqual(createdProject); + }); + + it('should return paginated projects', async () => { + const page = 1; + const limit = 10; + const searchTerm = 'test'; + const projects = [ + { id: 1, name: 'Test Project 1' }, + { id: 2, name: 'Test Project 2' }, + ]; + + repositoryMock.findAndCount.mockResolvedValue([projects, projects.length]); + + const result = await service.findAll(page, limit, searchTerm); + + expect(result.data).toEqual(projects); + expect(result.page).toEqual(page); + expect(result.perPage).toEqual(limit); + expect(result.totalItems).toEqual(projects.length); + expect(result.totalPages).toEqual(1); + }); + + it('should return paginated soft deleted projects', async () => { + const page = 1; + const limit = 10; + const owner = 'email@email.com'; + const projects = [ + { id: 1, name: 'Deleted Project 1' }, + { id: 2, name: 'Deleted Project 2' }, + ]; + + repositoryMock.findAndCount.mockResolvedValue([projects, projects.length]); + + const result = await service.findSoftDeleted(page, limit, owner); + + expect(repositoryMock.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + withDeleted: true, + where: { owner, deletedAt: expect.any(Object) }, + skip: 0, + take: 10, + }), + ); + expect(result.data).toEqual(projects); + expect(result.page).toEqual(page); + expect(result.perPage).toEqual(limit); + expect(result.totalItems).toEqual(projects.length); + expect(result.totalPages).toEqual(1); + }); + + it('should return a specific project', async () => { + const id = 1; + const project = { id, name: 'Test Project' }; + + repositoryMock.findOneBy.mockResolvedValue(project); + + const result = await service.findOne(id); + + expect(repositoryMock.findOneBy).toHaveBeenCalledWith({ id }); + expect(result).toEqual(project); + }); + + it('should update a project if the user is the owner', async () => { + const id = 1; + const updateProjectDto = { name: 'Updated Project' }; + const email = 'email@email.com'; + + const existingProject = { id, name: 'Test Project', owner: email }; + + repositoryMock.findOneBy.mockResolvedValue(existingProject); + + await service.update(id, updateProjectDto, email); + + expect(repositoryMock.findOneBy).toHaveBeenCalledWith({ id }); + expect(repositoryMock.update).toHaveBeenCalledWith( + { id }, + updateProjectDto, + ); + }); + + it('should throw NotOwnerException if the user is not the owner', async () => { + const id = 1; + const updateProjectDto = { name: 'Updated Project' }; + const email = 'email@email.com'; + + const existingProject = { + id, + name: 'Test Project', + owner: 'other@example.com', + }; + + repositoryMock.findOneBy.mockResolvedValue(existingProject); + + await expect(service.update(id, updateProjectDto, email)).rejects.toThrow( + NotOwnerException, + ); + + expect(repositoryMock.findOneBy).toHaveBeenCalledWith({ id }); + expect(repositoryMock.update).not.toHaveBeenCalled(); + }); + + it('should remove a project if the user is the owner', async () => { + const id = 1; + const email = 'email@email.com'; + + const existingProject = { id, name: 'Test Project', owner: email }; + + repositoryMock.findOneBy.mockResolvedValue(existingProject); + + await service.remove(id, email); + + expect(repositoryMock.findOneBy).toHaveBeenCalledWith({ id }); + expect(repositoryMock.softDelete).toHaveBeenCalledWith({ id }); + }); + + it('should throw NotOwnerException if the user is not the owner', async () => { + const id = 1; + const email = 'email@email.com'; + + const existingProject = { + id, + name: 'Test Project', + owner: 'other@example.com', + }; + + repositoryMock.findOneBy.mockResolvedValue(existingProject); + + await expect(service.remove(id, email)).rejects.toThrow(NotOwnerException); + + expect(repositoryMock.findOneBy).toHaveBeenCalledWith({ id }); + expect(repositoryMock.softDelete).not.toHaveBeenCalled(); + }); + + it('should restore a soft deleted project if the user is the owner', async () => { + const id = 1; + const email = 'email@email.com'; + + const existingProject = { id, name: 'Test Project', owner: email }; + + repositoryMock.findOne.mockResolvedValue(existingProject); + + await service.restore(id, email); + + expect(repositoryMock.findOne).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id }, + withDeleted: true, + }), + ); + expect(repositoryMock.restore).toHaveBeenCalledWith({ id }); + }); + + it('should throw NotOwnerException if the user is not the owner', async () => { + const id = 1; + const email = 'email@email.com'; + + const existingProject = { + id, + name: 'Test Project', + owner: 'other@email.com', + }; + + repositoryMock.findOne.mockResolvedValue(existingProject); + + await expect(service.restore(id, email)).rejects.toThrow(NotOwnerException); + + expect(repositoryMock.findOne).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id }, + withDeleted: true, + }), + ); + expect(repositoryMock.restore).not.toHaveBeenCalled(); + }); }); diff --git a/cozero-backend/src/projects/projects.service.ts b/cozero-backend/src/projects/projects.service.ts index c694306..fff3955 100644 --- a/cozero-backend/src/projects/projects.service.ts +++ b/cozero-backend/src/projects/projects.service.ts @@ -1,9 +1,11 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { ILike, IsNull, Not, Repository } from 'typeorm'; import { CreateProjectDto } from './dto/create-project.dto'; import { UpdateProjectDto } from './dto/update-project.dto'; import { Project } from './entities/project.entity'; +import { PaginatedProjectsDto } from './dto/paginated-projects.dto'; +import { NotOwnerException } from '../exceptions/not-owner.exception'; @Injectable() export class ProjectsService { @@ -16,19 +18,83 @@ export class ProjectsService { return await this.projectsRepository.save(createProjectDto); } - async findAll(): Promise { - return this.projectsRepository.find(); + async findAll( + page: number, + limit: number, + searchTerm: string, + ): Promise { + const skip = (page - 1) * limit; + const [data, totalItems] = await this.projectsRepository.findAndCount({ + where: [ + { name: ILike(`%${searchTerm}%`) }, + { description: ILike(`%${searchTerm}%`) }, + ], + order: { + createdAt: 'DESC', + }, + skip, + take: limit, + }); + + return { + data, + page, + perPage: limit, + totalItems, + totalPages: Math.ceil(totalItems / limit), + }; + } + + async findSoftDeleted( + page: number, + limit: number, + owner: string, + ): Promise { + const skip = (page - 1) * limit; + const [data, totalItems] = await this.projectsRepository.findAndCount({ + withDeleted: true, + where: { owner, deletedAt: Not(IsNull()) }, + skip, + take: limit, + }); + + return { + data, + page, + perPage: limit, + totalItems, + totalPages: Math.ceil(totalItems / limit), + }; } async findOne(id: number) { return await this.projectsRepository.findOneBy({ id }); } - async update(id: number, updateProjectDto: UpdateProjectDto) { + async update(id: number, updateProjectDto: UpdateProjectDto, email: string) { + const project = await this.findOne(id); + if (project.owner !== email) { + throw new NotOwnerException(); + } return this.projectsRepository.update({ id }, updateProjectDto); } - async remove(id: number) { - return this.projectsRepository.delete(id); + async remove(id: number, email: string) { + const project = await this.findOne(id); + if (project.owner !== email) { + throw new NotOwnerException(); + } + return this.projectsRepository.softDelete({ id }); + } + + async restore(id: number, email: string) { + const project = await this.projectsRepository.findOne({ + where: { id }, + withDeleted: true, // Include soft-deleted records + }); + if (project.owner !== email) { + throw new NotOwnerException(); + } + return this.projectsRepository.restore({ id }); } } diff --git a/cozero-backend/src/users/dto/user-login.dto.ts b/cozero-backend/src/users/dto/user-login.dto.ts index 46f1985..ed4b035 100644 --- a/cozero-backend/src/users/dto/user-login.dto.ts +++ b/cozero-backend/src/users/dto/user-login.dto.ts @@ -1,4 +1,10 @@ +import { IsEmail, IsNotEmpty, MinLength } from 'class-validator'; + export class UserLoginDto { + @IsEmail() + @IsNotEmpty() email: string; + @MinLength(6) + @IsNotEmpty() password: string; } diff --git a/cozero-backend/src/users/entities/user.entity.ts b/cozero-backend/src/users/entities/user.entity.ts index 260352b..64fc589 100644 --- a/cozero-backend/src/users/entities/user.entity.ts +++ b/cozero-backend/src/users/entities/user.entity.ts @@ -1,4 +1,4 @@ -import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn } from 'typeorm'; +import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; @Entity() export class User { diff --git a/cozero-backend/src/users/users.controller.spec.ts b/cozero-backend/src/users/users.controller.spec.ts index 3e27c39..b8a9364 100644 --- a/cozero-backend/src/users/users.controller.spec.ts +++ b/cozero-backend/src/users/users.controller.spec.ts @@ -1,18 +1,59 @@ import { Test, TestingModule } from '@nestjs/testing'; import { UsersController } from './users.controller'; +import { DuplicateEmailException, UsersService } from './users.service'; +import { UserLoginDto } from './dto/user-login.dto'; describe('UsersController', () => { let controller: UsersController; + let serviceMock: any; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [UsersController], + providers: [ + { + provide: UsersService, + useValue: { + findOne: jest.fn(), + create: jest.fn(), + }, + }, + ], }).compile(); controller = module.get(UsersController); + serviceMock = module.get(UsersService); }); - it('should be defined', () => { - expect(controller).toBeDefined(); + it('should create a new user', async () => { + const createUserDto: UserLoginDto = { + email: 'email@test.com', + password: 'password', + }; + + const expectedUser = { + email: createUserDto.email, + access_token: 'token', + }; + + serviceMock.create.mockReturnValue(expectedUser); + + const result = await controller.create(createUserDto); + + expect(result).toEqual(expectedUser); + expect(serviceMock.create).toHaveBeenCalledWith(createUserDto); + }); + + it('should not create a new user if email already exists', async () => { + const createUserDto: UserLoginDto = { + email: 'email@test.com', + password: 'password', + }; + const expectedError = new DuplicateEmailException(); + + serviceMock.create.mockRejectedValue(expectedError); + + await expect(controller.create(createUserDto)).rejects.toThrowError(); + expect(serviceMock.create).toHaveBeenCalledWith(createUserDto); }); }); diff --git a/cozero-backend/src/users/users.controller.ts b/cozero-backend/src/users/users.controller.ts index 9d86f83..0df7375 100644 --- a/cozero-backend/src/users/users.controller.ts +++ b/cozero-backend/src/users/users.controller.ts @@ -1,7 +1,7 @@ -import { Body, Controller, Post } from '@nestjs/common'; -import { SkipAuth } from 'src/decorators/skipAuth.decorator'; +import { Body, Controller, Get, Post } from '@nestjs/common'; import { UserLoginDto } from './dto/user-login.dto'; import { UsersService } from './users.service'; +import { SkipAuth } from '../decorators/skipAuth.decorator'; @Controller('users') export class UsersController { diff --git a/cozero-backend/src/users/users.module.ts b/cozero-backend/src/users/users.module.ts index fd4898a..4c135bc 100644 --- a/cozero-backend/src/users/users.module.ts +++ b/cozero-backend/src/users/users.module.ts @@ -3,8 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from './entities/user.entity'; import { UsersService } from './users.service'; import { UsersController } from './users.controller'; -import { AuthService } from 'src/auth/auth.service'; -import { AuthModule } from 'src/auth/auth.module'; +import { AuthModule } from '../auth/auth.module'; @Module({ imports: [TypeOrmModule.forFeature([User]), forwardRef(() => AuthModule)], diff --git a/cozero-backend/src/users/users.service.spec.ts b/cozero-backend/src/users/users.service.spec.ts index 62815ba..e8e4d6a 100644 --- a/cozero-backend/src/users/users.service.spec.ts +++ b/cozero-backend/src/users/users.service.spec.ts @@ -1,18 +1,86 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { JwtService } from '@nestjs/jwt'; +import { AuthService } from '../auth/auth.service'; import { UsersService } from './users.service'; +import { UserLoginDto } from './dto/user-login.dto'; +import { User } from './entities/user.entity'; describe('UsersService', () => { let service: UsersService; + let repositoryMock: any; + let jwtServiceMock: any; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [UsersService], + providers: [ + UsersService, + { + provide: getRepositoryToken(User), + useValue: { + create: jest.fn(), + findOneBy: jest.fn(), + save: jest.fn(), + }, + }, + { + provide: JwtService, + useValue: { + sign: jest.fn(), + }, + }, + AuthService, + ], }).compile(); service = module.get(UsersService); + repositoryMock = module.get(getRepositoryToken(User)); + jwtServiceMock = module.get(JwtService); }); it('should be defined', () => { expect(service).toBeDefined(); }); + + it('should create a new user', async () => { + const createUserDto: UserLoginDto = { + email: 'email@test.com', + password: 'password', + }; + + const expectedUser = { + email: createUserDto.email, + access_token: 'token', + }; + + repositoryMock.create.mockReturnValue(expectedUser); + repositoryMock.findOneBy.mockReturnValue(null); + repositoryMock.save.mockResolvedValue(expectedUser); + jwtServiceMock.sign.mockReturnValue('token'); + + const user = await service.create(createUserDto); + + expect(user).toEqual(expectedUser); + }); + + it('should not create a new user if email already exists', async () => { + const createUserDto: UserLoginDto = { + email: 'email@test.com', + password: 'password', + }; + + const expectedUser = { + email: createUserDto.email, + access_token: 'token', + }; + + repositoryMock.create.mockReturnValue(expectedUser); + repositoryMock.findOneBy.mockReturnValue({ + email: 'email@test.com', + }); + repositoryMock.save.mockResolvedValue(expectedUser); + jwtServiceMock.sign.mockReturnValue('token'); + + await expect(service.create(createUserDto)).rejects.toThrowError(); + }); }); diff --git a/cozero-backend/src/users/users.service.ts b/cozero-backend/src/users/users.service.ts index de9e51f..6a00c08 100644 --- a/cozero-backend/src/users/users.service.ts +++ b/cozero-backend/src/users/users.service.ts @@ -4,7 +4,8 @@ import { Repository } from 'typeorm'; import { UserLoginDto } from './dto/user-login.dto'; import { User } from './entities/user.entity'; import * as bcrypt from 'bcrypt'; -import { AuthService } from 'src/auth/auth.service'; +import { AuthService } from '../auth/auth.service'; +import { DuplicateEmailException } from 'src/exceptions/duplicated-email.exception'; @Injectable() export class UsersService { @@ -29,6 +30,8 @@ export class UsersService { if (!user) { user = await this.usersRepository.save(userWithHashedPassword); + } else { + throw new DuplicateEmailException(); } return this.authService.login(createUserDto); diff --git a/cozero-frontend/.prettierrc.json b/cozero-frontend/.prettierrc.json new file mode 100644 index 0000000..e74ed9f --- /dev/null +++ b/cozero-frontend/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "trailingComma": "es5", + "tabWidth": 4, + "semi": false, + "singleQuote": true +} diff --git a/cozero-frontend/components/ActionProjectConfirmation.tsx b/cozero-frontend/components/ActionProjectConfirmation.tsx new file mode 100644 index 0000000..427814c --- /dev/null +++ b/cozero-frontend/components/ActionProjectConfirmation.tsx @@ -0,0 +1,64 @@ +import { + Button, + AlertDialog, + AlertDialogOverlay, + AlertDialogContent, + AlertDialogHeader, + AlertDialogCloseButton, + AlertDialogBody, + AlertDialogFooter, +} from '@chakra-ui/react' +import React from 'react' +import { translate } from '../utils/language.utils' + +interface Props { + isOpen: boolean + onClose: () => void + onAction: () => void + isDeleted: boolean +} + +export default function ActionProjectConfirmation({ + isOpen, + onClose, + onAction, + isDeleted, +}: Props) { + const cancelRef = React.useRef(null) + + return ( + <> + + + + + + {isDeleted + ? translate('RESTORE_PROJECT') + : translate('DELETE_PROJECT')} + + + + {isDeleted + ? translate('RESTORE_PROJECT_DESCRIPTION') + : translate('DELETE_PROJECT_DESCRIPTION')} + + + + + + + + + ) +} diff --git a/cozero-frontend/components/DeleteProjectConfirmation.tsx b/cozero-frontend/components/DeleteProjectConfirmation.tsx deleted file mode 100644 index c62519b..0000000 --- a/cozero-frontend/components/DeleteProjectConfirmation.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Button, AlertDialog, AlertDialogOverlay, AlertDialogContent, AlertDialogHeader, AlertDialogCloseButton, AlertDialogBody, AlertDialogFooter } from "@chakra-ui/react" -import React from "react" -import { translate } from "../utils/language.utils" - -interface Props { - isOpen: boolean - onClose: () => void - onDelete: () => void -} - -export default function DeleteProjectConfirmation({ isOpen, onClose, onDelete }: Props) { - const cancelRef = React.useRef(null) - - return ( - <> - - - - - {translate('DELETE_PROJECT')} - - - {translate('DELETE_PROJECT_DESCRIPTION')} - - - - - - - - - ) -} \ No newline at end of file diff --git a/cozero-frontend/components/SignInButton.tsx b/cozero-frontend/components/SignInButton.tsx index 16a61e9..7e27ed9 100644 --- a/cozero-frontend/components/SignInButton.tsx +++ b/cozero-frontend/components/SignInButton.tsx @@ -1,4 +1,4 @@ -import { Avatar, Text, Flex } from "@chakra-ui/react"; +import { Avatar, Text, Flex, Container, Menu, MenuButton, MenuList, MenuItemOption } from "@chakra-ui/react"; import { useNavigate } from "react-router"; import { useAuth } from "../hooks/useAuth"; import { translate } from "../utils/language.utils"; @@ -8,9 +8,18 @@ export const SignInButton = () => { const { user, signOut } = useAuth() return ( - user ? signOut() : navigate('/sign-in')} gap={4} cursor='pointer'> - {translate(user ? 'SIGN_OUT' : 'SIGN_IN')} - + + user ? signOut() : navigate('/sign-in')} cursor='pointer'> + {translate(user ? 'SIGN_OUT' : 'SIGN_IN')} + + + + + + + navigate('/projects/deleted')}>Deleted Projects + + ) } \ No newline at end of file diff --git a/cozero-frontend/components/menu/Menu.tsx b/cozero-frontend/components/menu/Menu.tsx index e5f45de..49b4aaf 100644 --- a/cozero-frontend/components/menu/Menu.tsx +++ b/cozero-frontend/components/menu/Menu.tsx @@ -1,11 +1,11 @@ -import { Flex } from "@chakra-ui/react" -import MenuItem from "./MenuItem" +import { Flex } from '@chakra-ui/react' +import MenuItem from './MenuItem' -export default function Menu() { +export default function Menu() { return ( ) -} \ No newline at end of file +} diff --git a/cozero-frontend/components/menu/MenuItem.tsx b/cozero-frontend/components/menu/MenuItem.tsx index efbfac0..b0b37f4 100644 --- a/cozero-frontend/components/menu/MenuItem.tsx +++ b/cozero-frontend/components/menu/MenuItem.tsx @@ -1,25 +1,28 @@ import { Box, Text } from '@chakra-ui/react' -import { useLocation, useNavigate, useRoutes } from 'react-router'; +import { useLocation, useNavigate } from 'react-router' interface Props { - title: string; + title: string href: string } export default function MenuItem({ title, href }: Props) { - const navigate = useNavigate() - const router = useLocation() + const navigate = useNavigate() + const router = useLocation() - const handleClick = () => { - navigate(href) - } + const handleClick = () => { + navigate(href) + } - return ( - - - {title} - - - ) -} \ No newline at end of file + return ( + + {title} + + ) +} diff --git a/cozero-frontend/components/projects/DeletedProjectsEmptyState.tsx b/cozero-frontend/components/projects/DeletedProjectsEmptyState.tsx new file mode 100644 index 0000000..6a208a1 --- /dev/null +++ b/cozero-frontend/components/projects/DeletedProjectsEmptyState.tsx @@ -0,0 +1,24 @@ +import React from 'react' +import { Heading, Container, Text, Button } from '@chakra-ui/react' +import { translate } from '../../utils/language.utils' +import { useNavigate } from 'react-router' +import { MdMood } from 'react-icons/md' + +export const DeletedProjectsEmptyState = () => { + const navigate = useNavigate() + + return ( + + + + You don't have deleted projects + + + ) +} diff --git a/cozero-frontend/components/projects/DeletedProjectsList.tsx b/cozero-frontend/components/projects/DeletedProjectsList.tsx new file mode 100644 index 0000000..5b64a91 --- /dev/null +++ b/cozero-frontend/components/projects/DeletedProjectsList.tsx @@ -0,0 +1,50 @@ +import React, { useEffect } from 'react' +import { useSelector } from 'react-redux' +import { AppDispatch, useAppDispatch, RootState } from '../../store/store' +import { + fetchDeletedProjects, + restoreProject, +} from '../../store/deletedProjectsSlice' +import ProjectsList from '../../components/projects/ProjectsList' + +export default function DeletedProjectsList() { + const dispatch: AppDispatch = useAppDispatch() + + const projects = useSelector( + (state: RootState) => state.deletedProjectsState.projects + ) + const isLoading = useSelector( + (state: RootState) => state.deletedProjectsState.isLoading + ) + const error = useSelector( + (state: RootState) => state.deletedProjectsState.error + ) + const page = useSelector( + (state: RootState) => state.deletedProjectsState.projects?.page + ) + + useEffect(() => { + if (!projects) { + dispatch(fetchDeletedProjects(1)) + } + }, [projects]) + + const fetchMore = () => { + dispatch(fetchDeletedProjects(+(page ?? 0) + 1)) + } + + const onRestoreProject = (id: string) => { + dispatch(restoreProject(id)) + } + + return ( + + ) +} diff --git a/cozero-frontend/components/projects/ProjectForm.tsx b/cozero-frontend/components/projects/ProjectForm.tsx index 54589a9..1aa2643 100644 --- a/cozero-frontend/components/projects/ProjectForm.tsx +++ b/cozero-frontend/components/projects/ProjectForm.tsx @@ -1,167 +1,268 @@ -import { Box, Button, Flex, FormControl, FormHelperText, FormLabel, Input, List, ListIcon, ListItem, RangeSlider, RangeSliderFilledTrack, RangeSliderThumb, RangeSliderTrack, Stack, Text, Textarea, useToast } from "@chakra-ui/react"; -import { FaLeaf } from "react-icons/fa" -import { BsFillTrashFill } from "react-icons/bs" -import { TbLeaf } from "react-icons/tb" -import { useForm, useFieldArray } from "react-hook-form"; -import { CreateProjectDto, ProjectForm as IProjectForm, Project, UpdateProjectDto } from "../../interfaces/project.interface"; -import { useCallback, useContext, useEffect, useState } from "react"; -import { createProjectDefaultValues } from "../../constants/project.constants"; -import ProjectsService from "../../services/ProjectsService"; -import { translate } from "../../utils/language.utils"; -import { ProjectInformation } from "../../enums/projectInformation.enum"; -import { getProjectResponseTranslation, projectFormToProjectDTO } from "../../utils/project.utils"; -import { useNavigate, useParams } from "react-router"; -import { AuthContext } from "../../context/auth"; +import { + Box, + Button, + Flex, + FormControl, + FormHelperText, + FormLabel, + Input, + List, + ListIcon, + ListItem, + RangeSlider, + RangeSliderFilledTrack, + RangeSliderThumb, + RangeSliderTrack, + Stack, + Text, + Textarea, + useToast, +} from '@chakra-ui/react' +import { FaLeaf } from 'react-icons/fa' +import { BsFillTrashFill } from 'react-icons/bs' +import { TbLeaf } from 'react-icons/tb' +import { useForm, useFieldArray } from 'react-hook-form' +import { + CreateProjectDto, + ProjectForm as IProjectForm, + Project, + UpdateProjectDto, +} from '../../interfaces/project.interface' +import { useCallback, useContext, useEffect, useState } from 'react' +import { createProjectDefaultValues } from '../../constants/project.constants' +import ProjectsService from '../../services/ProjectsService' +import { translate } from '../../utils/language.utils' +import { ProjectInformation } from '../../enums/projectInformation.enum' +import { + getProjectResponseTranslation, + projectFormToProjectDTO, +} from '../../utils/project.utils' +import { useNavigate, useParams } from 'react-router' +import { AuthContext } from '../../context/auth' +import { AppDispatch, RootState, useAppDispatch } from '../../store/store' +import { createProject, updateProject } from '../../store/projectsSlice' +import { fetchProjectById } from '../../store/projectDetailsSlice' +import { useSelector } from 'react-redux' export default function ProjectForm() { + const dispatch: AppDispatch = useAppDispatch() + const { id } = useParams() - const [listItem, setListItem] = useState(""); - const [isProcessing, setIsProcessing] = useState(false); + const projectDetails = useSelector( + (state: RootState) => state.projectDetailsState.projectDetails + ) + + // TODO: add loading + const isDetailLoading = useSelector( + (state: RootState) => state.projectDetailsState.isLoading + ) + const isMutationLoading = useSelector( + (state: RootState) => state.projectsState.isLoading + ) + const isMutationError = useSelector( + (state: RootState) => state.projectsState.error + ) + + const [listItem, setListItem] = useState('') + const [isProcessing, setIsProcessing] = useState(false) const toast = useToast() const navigate = useNavigate() - const { register, handleSubmit, watch, control, setValue, reset } = useForm({ - defaultValues: createProjectDefaultValues - }) + const { register, handleSubmit, watch, control, setValue, reset } = + useForm({ + defaultValues: createProjectDefaultValues, + }) const { context } = useContext(AuthContext) const { append, remove } = useFieldArray({ control, - name: "listing", - }); + name: 'listing', + }) - const fetchProjectById = useCallback(async (id: string) => { - const project = await ProjectsService.fetchProjectById(id) + useEffect(() => { + if (projectDetails) { + reset({ + ...projectDetails, + listing: projectDetails.listing.map((item) => { + return { + id: Date.now().toString() + Math.random().toString(), + name: item, + } + }), + co2EstimateReduction: { + min: projectDetails.co2EstimateReduction[0], + max: projectDetails.co2EstimateReduction[1], + }, + }) + } + }, [projectDetails]) - if (!project) { - return + useEffect(() => { + if (id) { + dispatch(fetchProjectById(id)) } + }, [id]) - reset({ - ...project, - listing: project.listing.map(item => { - return { - id: Date.now().toString() + Math.random().toString(), - name: item - } - }), - co2EstimateReduction: { - min: project.co2EstimateReduction[0], - max: project.co2EstimateReduction[1] - } - }) + useEffect(() => { + if (isProcessing && !isMutationLoading) { + const isSuccess = !isMutationError - }, [reset]) + const { title, description } = getProjectResponseTranslation( + isSuccess, + !!id + ) + toast({ + title, + description, + status: isSuccess ? 'success' : 'error', + duration: 9000, + isClosable: true, + }) - useEffect(() => { - if (id) { - fetchProjectById(id) + if (isSuccess) { + navigate('/projects') + } } - }, [id, fetchProjectById]) + }, [isProcessing, isMutationLoading, isMutationError]) const onKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && !!listItem) { + if (e.key === 'Enter' && !!listItem) { append({ name: listItem, - id: Date.now().toString() + Math.random() // Not the best way to do this, but it works for this example + id: Date.now().toString() + Math.random(), // Not the best way to do this, but it works for this example }) - setListItem(""); - e.preventDefault(); + setListItem('') + e.preventDefault() } } const onSubmitForm = async (projectForm: IProjectForm) => { - setIsProcessing(true); - - const project: CreateProjectDto | UpdateProjectDto = projectFormToProjectDTO(projectForm, context?.user?.email as string) - - const projectResponse = id ? await ProjectsService.updateProject(project as UpdateProjectDto) : await ProjectsService.createProject(project as CreateProjectDto); - setIsProcessing(false); - - const { title, description } = getProjectResponseTranslation(!!projectResponse, !!id); - - toast({ - title, - description, - status: projectResponse ? 'success' : 'error', - duration: 9000, - isClosable: true, - }) - - if (projectResponse) { - navigate("/projects") - } + const project: CreateProjectDto | UpdateProjectDto = + projectFormToProjectDTO(projectForm, context?.user?.email as string) + id + ? dispatch(updateProject(project as UpdateProjectDto)) + : dispatch(createProject(project)) + setIsProcessing(true) } - const { co2EstimateReduction: { min, max }, listing, name } = watch() + const { + co2EstimateReduction: { min, max }, + listing, + name, + } = watch() return (
{translate('PROJECT_NAME')} - + {translate('PROJECT_DESCRIPTION')} -