From 9a10da7c159f70ef2de4c1b723f2ae8dad34dcfe Mon Sep 17 00:00:00 2001 From: gabriel Date: Sat, 18 Oct 2025 13:30:52 -0300 Subject: [PATCH 01/10] test: get all comments --- package-lock.json | 1425 ++++++++--------- package.json | 2 +- .../v1/get-all-comments.spec.ts | 73 + test/module.ts | 4 +- test/test-helpers.ts | 20 + test/test-setup.ts | 1 + 6 files changed, 790 insertions(+), 735 deletions(-) create mode 100644 src/features/feed/comments/get-all-comments/v1/get-all-comments.spec.ts diff --git a/package-lock.json b/package-lock.json index 3fb4d94..c15d088 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "2.0.0", "license": "UNLICENSED", "dependencies": { - "@aws-sdk/client-s3": "^3.893.0", + "@aws-sdk/client-s3": "^3.908.0", "@aws-sdk/client-sesv2": "^3.890.0", "@nestjs/common": "^11.1.6", "@nestjs/config": "^4.0.2", @@ -402,117 +402,116 @@ } }, "node_modules/@aws-sdk/client-s3": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.893.0.tgz", - "integrity": "sha512-/P74KDJhOijnIAQR93sq1DQn8vbU3WaPZDyy1XUMRJJIY6iEJnDo1toD9XY6AFDz5TRto8/8NbcXT30AMOUtJQ==", + "version": "3.908.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.908.0.tgz", + "integrity": "sha512-c/89iG3of8UEiWbRK014DoHLy8PLLTJtM9IvYLPsvrf83kpV2P/K9WrdbjW4h6e5qt9XPgfNTZ8U607mt7pdmA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.893.0", - "@aws-sdk/credential-provider-node": "3.893.0", - "@aws-sdk/middleware-bucket-endpoint": "3.893.0", - "@aws-sdk/middleware-expect-continue": "3.893.0", - "@aws-sdk/middleware-flexible-checksums": "3.893.0", - "@aws-sdk/middleware-host-header": "3.893.0", - "@aws-sdk/middleware-location-constraint": "3.893.0", - "@aws-sdk/middleware-logger": "3.893.0", - "@aws-sdk/middleware-recursion-detection": "3.893.0", - "@aws-sdk/middleware-sdk-s3": "3.893.0", - "@aws-sdk/middleware-ssec": "3.893.0", - "@aws-sdk/middleware-user-agent": "3.893.0", - "@aws-sdk/region-config-resolver": "3.893.0", - "@aws-sdk/signature-v4-multi-region": "3.893.0", - "@aws-sdk/types": "3.893.0", - "@aws-sdk/util-endpoints": "3.893.0", - "@aws-sdk/util-user-agent-browser": "3.893.0", - "@aws-sdk/util-user-agent-node": "3.893.0", - "@aws-sdk/xml-builder": "3.893.0", - "@smithy/config-resolver": "^4.2.2", - "@smithy/core": "^3.11.1", - "@smithy/eventstream-serde-browser": "^4.1.1", - "@smithy/eventstream-serde-config-resolver": "^4.2.1", - "@smithy/eventstream-serde-node": "^4.1.1", - "@smithy/fetch-http-handler": "^5.2.1", - "@smithy/hash-blob-browser": "^4.1.1", - "@smithy/hash-node": "^4.1.1", - "@smithy/hash-stream-node": "^4.1.1", - "@smithy/invalid-dependency": "^4.1.1", - "@smithy/md5-js": "^4.1.1", - "@smithy/middleware-content-length": "^4.1.1", - "@smithy/middleware-endpoint": "^4.2.3", - "@smithy/middleware-retry": "^4.2.4", - "@smithy/middleware-serde": "^4.1.1", - "@smithy/middleware-stack": "^4.1.1", - "@smithy/node-config-provider": "^4.2.2", - "@smithy/node-http-handler": "^4.2.1", - "@smithy/protocol-http": "^5.2.1", - "@smithy/smithy-client": "^4.6.3", - "@smithy/types": "^4.5.0", - "@smithy/url-parser": "^4.1.1", - "@smithy/util-base64": "^4.1.0", - "@smithy/util-body-length-browser": "^4.1.0", - "@smithy/util-body-length-node": "^4.1.0", - "@smithy/util-defaults-mode-browser": "^4.1.3", - "@smithy/util-defaults-mode-node": "^4.1.3", - "@smithy/util-endpoints": "^3.1.2", - "@smithy/util-middleware": "^4.1.1", - "@smithy/util-retry": "^4.1.2", - "@smithy/util-stream": "^4.3.2", - "@smithy/util-utf8": "^4.1.0", - "@smithy/util-waiter": "^4.1.1", - "@types/uuid": "^9.0.1", - "tslib": "^2.6.2", - "uuid": "^9.0.1" + "@aws-sdk/core": "3.908.0", + "@aws-sdk/credential-provider-node": "3.908.0", + "@aws-sdk/middleware-bucket-endpoint": "3.901.0", + "@aws-sdk/middleware-expect-continue": "3.901.0", + "@aws-sdk/middleware-flexible-checksums": "3.908.0", + "@aws-sdk/middleware-host-header": "3.901.0", + "@aws-sdk/middleware-location-constraint": "3.901.0", + "@aws-sdk/middleware-logger": "3.901.0", + "@aws-sdk/middleware-recursion-detection": "3.901.0", + "@aws-sdk/middleware-sdk-s3": "3.908.0", + "@aws-sdk/middleware-ssec": "3.901.0", + "@aws-sdk/middleware-user-agent": "3.908.0", + "@aws-sdk/region-config-resolver": "3.901.0", + "@aws-sdk/signature-v4-multi-region": "3.908.0", + "@aws-sdk/types": "3.901.0", + "@aws-sdk/util-endpoints": "3.901.0", + "@aws-sdk/util-user-agent-browser": "3.907.0", + "@aws-sdk/util-user-agent-node": "3.908.0", + "@aws-sdk/xml-builder": "3.901.0", + "@smithy/config-resolver": "^4.3.0", + "@smithy/core": "^3.15.0", + "@smithy/eventstream-serde-browser": "^4.2.0", + "@smithy/eventstream-serde-config-resolver": "^4.3.0", + "@smithy/eventstream-serde-node": "^4.2.0", + "@smithy/fetch-http-handler": "^5.3.1", + "@smithy/hash-blob-browser": "^4.2.1", + "@smithy/hash-node": "^4.2.0", + "@smithy/hash-stream-node": "^4.2.0", + "@smithy/invalid-dependency": "^4.2.0", + "@smithy/md5-js": "^4.2.0", + "@smithy/middleware-content-length": "^4.2.0", + "@smithy/middleware-endpoint": "^4.3.1", + "@smithy/middleware-retry": "^4.4.1", + "@smithy/middleware-serde": "^4.2.0", + "@smithy/middleware-stack": "^4.2.0", + "@smithy/node-config-provider": "^4.3.0", + "@smithy/node-http-handler": "^4.3.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/smithy-client": "^4.7.1", + "@smithy/types": "^4.6.0", + "@smithy/url-parser": "^4.2.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.0", + "@smithy/util-defaults-mode-node": "^4.2.1", + "@smithy/util-endpoints": "^3.2.0", + "@smithy/util-middleware": "^4.2.0", + "@smithy/util-retry": "^4.2.0", + "@smithy/util-stream": "^4.5.0", + "@smithy/util-utf8": "^4.2.0", + "@smithy/util-waiter": "^4.2.0", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/client-sso": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.893.0.tgz", - "integrity": "sha512-0+qRGq7H8UNfxI0F02ObyOgOiYxkN4DSlFfwQUQMPfqENDNYOrL++2H9X3EInyc1lUM/+aK8TZqSbh473gdxcg==", + "version": "3.908.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.908.0.tgz", + "integrity": "sha512-PseFMWvtac+Q+zaY9DMISE+2+glNh0ROJ1yR4gMzeafNHSwkdYu4qcgxLWIOnIodGydBv/tQ6nzHPzExXnUUgw==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.893.0", - "@aws-sdk/middleware-host-header": "3.893.0", - "@aws-sdk/middleware-logger": "3.893.0", - "@aws-sdk/middleware-recursion-detection": "3.893.0", - "@aws-sdk/middleware-user-agent": "3.893.0", - "@aws-sdk/region-config-resolver": "3.893.0", - "@aws-sdk/types": "3.893.0", - "@aws-sdk/util-endpoints": "3.893.0", - "@aws-sdk/util-user-agent-browser": "3.893.0", - "@aws-sdk/util-user-agent-node": "3.893.0", - "@smithy/config-resolver": "^4.2.2", - "@smithy/core": "^3.11.1", - "@smithy/fetch-http-handler": "^5.2.1", - "@smithy/hash-node": "^4.1.1", - "@smithy/invalid-dependency": "^4.1.1", - "@smithy/middleware-content-length": "^4.1.1", - "@smithy/middleware-endpoint": "^4.2.3", - "@smithy/middleware-retry": "^4.2.4", - "@smithy/middleware-serde": "^4.1.1", - "@smithy/middleware-stack": "^4.1.1", - "@smithy/node-config-provider": "^4.2.2", - "@smithy/node-http-handler": "^4.2.1", - "@smithy/protocol-http": "^5.2.1", - "@smithy/smithy-client": "^4.6.3", - "@smithy/types": "^4.5.0", - "@smithy/url-parser": "^4.1.1", - "@smithy/util-base64": "^4.1.0", - "@smithy/util-body-length-browser": "^4.1.0", - "@smithy/util-body-length-node": "^4.1.0", - "@smithy/util-defaults-mode-browser": "^4.1.3", - "@smithy/util-defaults-mode-node": "^4.1.3", - "@smithy/util-endpoints": "^3.1.2", - "@smithy/util-middleware": "^4.1.1", - "@smithy/util-retry": "^4.1.2", - "@smithy/util-utf8": "^4.1.0", + "@aws-sdk/core": "3.908.0", + "@aws-sdk/middleware-host-header": "3.901.0", + "@aws-sdk/middleware-logger": "3.901.0", + "@aws-sdk/middleware-recursion-detection": "3.901.0", + "@aws-sdk/middleware-user-agent": "3.908.0", + "@aws-sdk/region-config-resolver": "3.901.0", + "@aws-sdk/types": "3.901.0", + "@aws-sdk/util-endpoints": "3.901.0", + "@aws-sdk/util-user-agent-browser": "3.907.0", + "@aws-sdk/util-user-agent-node": "3.908.0", + "@smithy/config-resolver": "^4.3.0", + "@smithy/core": "^3.15.0", + "@smithy/fetch-http-handler": "^5.3.1", + "@smithy/hash-node": "^4.2.0", + "@smithy/invalid-dependency": "^4.2.0", + "@smithy/middleware-content-length": "^4.2.0", + "@smithy/middleware-endpoint": "^4.3.1", + "@smithy/middleware-retry": "^4.4.1", + "@smithy/middleware-serde": "^4.2.0", + "@smithy/middleware-stack": "^4.2.0", + "@smithy/node-config-provider": "^4.3.0", + "@smithy/node-http-handler": "^4.3.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/smithy-client": "^4.7.1", + "@smithy/types": "^4.6.0", + "@smithy/url-parser": "^4.2.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.0", + "@smithy/util-defaults-mode-node": "^4.2.1", + "@smithy/util-endpoints": "^3.2.0", + "@smithy/util-middleware": "^4.2.0", + "@smithy/util-retry": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -520,25 +519,23 @@ } }, "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/core": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.893.0.tgz", - "integrity": "sha512-E1NAWHOprBXIJ9CVb6oTsRD/tNOozrKBD/Sb4t7WZd3dpby6KpYfM6FaEGfRGcJBIcB4245hww8Rmg16qDMJWg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.893.0", - "@aws-sdk/xml-builder": "3.893.0", - "@smithy/core": "^3.11.1", - "@smithy/node-config-provider": "^4.2.2", - "@smithy/property-provider": "^4.1.1", - "@smithy/protocol-http": "^5.2.1", - "@smithy/signature-v4": "^5.2.1", - "@smithy/smithy-client": "^4.6.3", - "@smithy/types": "^4.5.0", - "@smithy/util-base64": "^4.1.0", - "@smithy/util-body-length-browser": "^4.1.0", - "@smithy/util-middleware": "^4.1.1", - "@smithy/util-utf8": "^4.1.0", - "fast-xml-parser": "5.2.5", + "version": "3.908.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.908.0.tgz", + "integrity": "sha512-okl6FC2cQT1Oidvmnmvyp/IEvqENBagKO0ww4YV5UtBkf0VlhAymCWkZqhovtklsqgq0otag2VRPAgnrMt6nVQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.901.0", + "@aws-sdk/xml-builder": "3.901.0", + "@smithy/core": "^3.15.0", + "@smithy/node-config-provider": "^4.3.0", + "@smithy/property-provider": "^4.2.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/signature-v4": "^5.3.0", + "@smithy/smithy-client": "^4.7.1", + "@smithy/types": "^4.6.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-middleware": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -546,15 +543,15 @@ } }, "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-env": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.893.0.tgz", - "integrity": "sha512-h4sYNk1iDrSZQLqFfbuD1GWY6KoVCvourfqPl6JZCYj8Vmnox5y9+7taPxwlU2VVII0hiV8UUbO79P35oPBSyA==", + "version": "3.908.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.908.0.tgz", + "integrity": "sha512-FK2YuxoI5CxUflPOIMbVAwDbi6Xvu+2sXopXLmrHc2PfI39M3vmjEoQwYCP8WuQSRb+TbAP3xAkxHjFSBFR35w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.893.0", - "@aws-sdk/types": "3.893.0", - "@smithy/property-provider": "^4.1.1", - "@smithy/types": "^4.5.0", + "@aws-sdk/core": "3.908.0", + "@aws-sdk/types": "3.901.0", + "@smithy/property-provider": "^4.2.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -562,20 +559,20 @@ } }, "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-http": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.893.0.tgz", - "integrity": "sha512-xYoC7DRr++zWZ9jG7/hvd6YjCbGDQzsAu2fBHHf91RVmSETXUgdEaP9rOdfCM02iIK/MYlwiWEIVBcBxWY/GQw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.893.0", - "@aws-sdk/types": "3.893.0", - "@smithy/fetch-http-handler": "^5.2.1", - "@smithy/node-http-handler": "^4.2.1", - "@smithy/property-provider": "^4.1.1", - "@smithy/protocol-http": "^5.2.1", - "@smithy/smithy-client": "^4.6.3", - "@smithy/types": "^4.5.0", - "@smithy/util-stream": "^4.3.2", + "version": "3.908.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.908.0.tgz", + "integrity": "sha512-eLbz0geVW9EykujQNnYfR35Of8MreI6pau5K6XDFDUSWO9GF8wqH7CQwbXpXHBlCTHtq4QSLxzorD8U5CROhUw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.908.0", + "@aws-sdk/types": "3.901.0", + "@smithy/fetch-http-handler": "^5.3.1", + "@smithy/node-http-handler": "^4.3.0", + "@smithy/property-provider": "^4.2.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/smithy-client": "^4.7.1", + "@smithy/types": "^4.6.0", + "@smithy/util-stream": "^4.5.0", "tslib": "^2.6.2" }, "engines": { @@ -583,23 +580,23 @@ } }, "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.893.0.tgz", - "integrity": "sha512-ZQWOl4jdLhJHHrHsOfNRjgpP98A5kw4YzkMOUoK+TgSQVLi7wjb957V0htvwpi6KmGr3f2F8J06D6u2OtIc62w==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.893.0", - "@aws-sdk/credential-provider-env": "3.893.0", - "@aws-sdk/credential-provider-http": "3.893.0", - "@aws-sdk/credential-provider-process": "3.893.0", - "@aws-sdk/credential-provider-sso": "3.893.0", - "@aws-sdk/credential-provider-web-identity": "3.893.0", - "@aws-sdk/nested-clients": "3.893.0", - "@aws-sdk/types": "3.893.0", - "@smithy/credential-provider-imds": "^4.1.2", - "@smithy/property-provider": "^4.1.1", - "@smithy/shared-ini-file-loader": "^4.2.0", - "@smithy/types": "^4.5.0", + "version": "3.908.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.908.0.tgz", + "integrity": "sha512-7Cgnv5wabgFtsgr+Uc/76EfPNGyxmbG8aICn3g3D3iJlcO4uuOZI8a77i0afoDdchZrTC6TG6UusS/NAW6zEoQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.908.0", + "@aws-sdk/credential-provider-env": "3.908.0", + "@aws-sdk/credential-provider-http": "3.908.0", + "@aws-sdk/credential-provider-process": "3.908.0", + "@aws-sdk/credential-provider-sso": "3.908.0", + "@aws-sdk/credential-provider-web-identity": "3.908.0", + "@aws-sdk/nested-clients": "3.908.0", + "@aws-sdk/types": "3.901.0", + "@smithy/credential-provider-imds": "^4.2.0", + "@smithy/property-provider": "^4.2.0", + "@smithy/shared-ini-file-loader": "^4.3.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -607,22 +604,22 @@ } }, "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-node": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.893.0.tgz", - "integrity": "sha512-NjvDUXciC2+EaQnbL/u/ZuCXj9PZQ/9ciPhI62LGCoJ3ft91lI1Z58Dgut0OFPpV6i16GhpFxzmbuf40wTgDbA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/credential-provider-env": "3.893.0", - "@aws-sdk/credential-provider-http": "3.893.0", - "@aws-sdk/credential-provider-ini": "3.893.0", - "@aws-sdk/credential-provider-process": "3.893.0", - "@aws-sdk/credential-provider-sso": "3.893.0", - "@aws-sdk/credential-provider-web-identity": "3.893.0", - "@aws-sdk/types": "3.893.0", - "@smithy/credential-provider-imds": "^4.1.2", - "@smithy/property-provider": "^4.1.1", - "@smithy/shared-ini-file-loader": "^4.2.0", - "@smithy/types": "^4.5.0", + "version": "3.908.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.908.0.tgz", + "integrity": "sha512-8OKbykpGw5bdfF/pLTf8YfUi1Kl8o1CTjBqWQTsLOkE3Ho3hsp1eQx8Cz4ttrpv0919kb+lox62DgmAOEmTr1w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.908.0", + "@aws-sdk/credential-provider-http": "3.908.0", + "@aws-sdk/credential-provider-ini": "3.908.0", + "@aws-sdk/credential-provider-process": "3.908.0", + "@aws-sdk/credential-provider-sso": "3.908.0", + "@aws-sdk/credential-provider-web-identity": "3.908.0", + "@aws-sdk/types": "3.901.0", + "@smithy/credential-provider-imds": "^4.2.0", + "@smithy/property-provider": "^4.2.0", + "@smithy/shared-ini-file-loader": "^4.3.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -630,16 +627,16 @@ } }, "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-process": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.893.0.tgz", - "integrity": "sha512-5XitkZdiQhjWJV71qWqrH7hMXwuK/TvIRwiwKs7Pj0sapGSk3YgD3Ykdlolz7sQOleoKWYYqgoq73fIPpTTmFA==", + "version": "3.908.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.908.0.tgz", + "integrity": "sha512-sWnbkGjDPBi6sODUzrAh5BCDpnPw0wpK8UC/hWI13Q8KGfyatAmCBfr+9OeO3+xBHa8N5AskMncr7C4qS846yQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.893.0", - "@aws-sdk/types": "3.893.0", - "@smithy/property-provider": "^4.1.1", - "@smithy/shared-ini-file-loader": "^4.2.0", - "@smithy/types": "^4.5.0", + "@aws-sdk/core": "3.908.0", + "@aws-sdk/types": "3.901.0", + "@smithy/property-provider": "^4.2.0", + "@smithy/shared-ini-file-loader": "^4.3.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -647,18 +644,18 @@ } }, "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.893.0.tgz", - "integrity": "sha512-ms8v13G1r0aHZh5PLcJu6AnQZPs23sRm3Ph0A7+GdqbPvWewP8M7jgZTKyTXi+oYXswdYECU1zPVur8zamhtLg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/client-sso": "3.893.0", - "@aws-sdk/core": "3.893.0", - "@aws-sdk/token-providers": "3.893.0", - "@aws-sdk/types": "3.893.0", - "@smithy/property-provider": "^4.1.1", - "@smithy/shared-ini-file-loader": "^4.2.0", - "@smithy/types": "^4.5.0", + "version": "3.908.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.908.0.tgz", + "integrity": "sha512-WV/aOzuS6ZZhrkPty6TJ3ZG24iS8NXP0m3GuTVuZ5tKi9Guss31/PJ1CrKPRCYGm15CsIjf+mrUxVnNYv9ap5g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.908.0", + "@aws-sdk/core": "3.908.0", + "@aws-sdk/token-providers": "3.908.0", + "@aws-sdk/types": "3.901.0", + "@smithy/property-provider": "^4.2.0", + "@smithy/shared-ini-file-loader": "^4.3.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -666,17 +663,17 @@ } }, "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.893.0.tgz", - "integrity": "sha512-wWD8r2ot4jf/CoogdPTl13HbwNLW4UheGUCu6gW7n9GoHh1JImYyooPHK8K7kD42hihydIA7OW7iFAf7//JYTw==", + "version": "3.908.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.908.0.tgz", + "integrity": "sha512-9xWrFn6nWlF5KlV4XYW+7E6F33S3wUUEGRZ/+pgDhkIZd527ycT2nPG2dZ3fWUZMlRmzijP20QIJDqEbbGWe1Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.893.0", - "@aws-sdk/nested-clients": "3.893.0", - "@aws-sdk/types": "3.893.0", - "@smithy/property-provider": "^4.1.1", - "@smithy/shared-ini-file-loader": "^4.2.0", - "@smithy/types": "^4.5.0", + "@aws-sdk/core": "3.908.0", + "@aws-sdk/nested-clients": "3.908.0", + "@aws-sdk/types": "3.901.0", + "@smithy/property-provider": "^4.2.0", + "@smithy/shared-ini-file-loader": "^4.3.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -684,14 +681,14 @@ } }, "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/middleware-host-header": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.893.0.tgz", - "integrity": "sha512-qL5xYRt80ahDfj9nDYLhpCNkDinEXvjLe/Qen/Y/u12+djrR2MB4DRa6mzBCkLkdXDtf0WAoW2EZsNCfGrmOEQ==", + "version": "3.901.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.901.0.tgz", + "integrity": "sha512-yWX7GvRmqBtbNnUW7qbre3GvZmyYwU0WHefpZzDTYDoNgatuYq6LgUIQ+z5C04/kCRoFkAFrHag8a3BXqFzq5A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.893.0", - "@smithy/protocol-http": "^5.2.1", - "@smithy/types": "^4.5.0", + "@aws-sdk/types": "3.901.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -699,13 +696,13 @@ } }, "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/middleware-logger": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.893.0.tgz", - "integrity": "sha512-ZqzMecjju5zkBquSIfVfCORI/3Mge21nUY4nWaGQy+NUXehqCGG4W7AiVpiHGOcY2cGJa7xeEkYcr2E2U9U0AA==", + "version": "3.901.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.901.0.tgz", + "integrity": "sha512-UoHebjE7el/tfRo8/CQTj91oNUm+5Heus5/a4ECdmWaSCHCS/hXTsU3PTTHAY67oAQR8wBLFPfp3mMvXjB+L2A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.893.0", - "@smithy/types": "^4.5.0", + "@aws-sdk/types": "3.901.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -713,15 +710,15 @@ } }, "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.893.0.tgz", - "integrity": "sha512-H7Zotd9zUHQAr/wr3bcWHULYhEeoQrF54artgsoUGIf/9emv6LzY89QUccKIxYd6oHKNTrTyXm9F0ZZrzXNxlg==", + "version": "3.901.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.901.0.tgz", + "integrity": "sha512-Wd2t8qa/4OL0v/oDpCHHYkgsXJr8/ttCxrvCKAt0H1zZe2LlRhY9gpDVKqdertfHrHDj786fOvEQA28G1L75Dg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.893.0", + "@aws-sdk/types": "3.901.0", "@aws/lambda-invoke-store": "^0.0.1", - "@smithy/protocol-http": "^5.2.1", - "@smithy/types": "^4.5.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -729,24 +726,24 @@ } }, "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.893.0.tgz", - "integrity": "sha512-J2v7jOoSlE4o416yQiuka6+cVJzyrU7mbJEQA9VFCb+TYT2cG3xQB0bDzE24QoHeonpeBDghbg/zamYMnt+GsQ==", + "version": "3.908.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.908.0.tgz", + "integrity": "sha512-23MbAOHsGaD0kTVMVLumaIM1f9vtDImIn2lSvPullbjFHKS4XxfrKuPumtKDzl8gzcux+98XnmfDRKH0fzkOUA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.893.0", - "@aws-sdk/types": "3.893.0", + "@aws-sdk/core": "3.908.0", + "@aws-sdk/types": "3.901.0", "@aws-sdk/util-arn-parser": "3.893.0", - "@smithy/core": "^3.11.1", - "@smithy/node-config-provider": "^4.2.2", - "@smithy/protocol-http": "^5.2.1", - "@smithy/signature-v4": "^5.2.1", - "@smithy/smithy-client": "^4.6.3", - "@smithy/types": "^4.5.0", - "@smithy/util-config-provider": "^4.1.0", - "@smithy/util-middleware": "^4.1.1", - "@smithy/util-stream": "^4.3.2", - "@smithy/util-utf8": "^4.1.0", + "@smithy/core": "^3.15.0", + "@smithy/node-config-provider": "^4.3.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/signature-v4": "^5.3.0", + "@smithy/smithy-client": "^4.7.1", + "@smithy/types": "^4.6.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-middleware": "^4.2.0", + "@smithy/util-stream": "^4.5.0", + "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -754,17 +751,17 @@ } }, "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.893.0.tgz", - "integrity": "sha512-n1vHj7bdC4ycIAKkny0rmgvgvGOIgYnGBAqfPAFPR26WuGWmCxH2cT9nQTNA+li8ofxX9F9FIFBTKkW92Pc8iQ==", + "version": "3.908.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.908.0.tgz", + "integrity": "sha512-R0ePEOku72EvyJWy/D0Z5f/Ifpfxa0U9gySO3stpNhOox87XhsILpcIsCHPy0OHz1a7cMoZsF6rMKSzDeCnogQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.893.0", - "@aws-sdk/types": "3.893.0", - "@aws-sdk/util-endpoints": "3.893.0", - "@smithy/core": "^3.11.1", - "@smithy/protocol-http": "^5.2.1", - "@smithy/types": "^4.5.0", + "@aws-sdk/core": "3.908.0", + "@aws-sdk/types": "3.901.0", + "@aws-sdk/util-endpoints": "3.901.0", + "@smithy/core": "^3.15.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -772,48 +769,48 @@ } }, "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/nested-clients": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.893.0.tgz", - "integrity": "sha512-HIUCyNtWkxvc0BmaJPUj/A0/29OapT/dzBNxr2sjgKNZgO81JuDFp+aXCmnf7vQoA2D1RzCsAIgEtfTExNFZqA==", + "version": "3.908.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.908.0.tgz", + "integrity": "sha512-ZxDYrfxOKXNFHLyvJtT96TJ0p4brZOhwRE4csRXrezEVUN+pNgxuem95YvMALPVhlVqON2CTzr8BX+CcBKvX9Q==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.893.0", - "@aws-sdk/middleware-host-header": "3.893.0", - "@aws-sdk/middleware-logger": "3.893.0", - "@aws-sdk/middleware-recursion-detection": "3.893.0", - "@aws-sdk/middleware-user-agent": "3.893.0", - "@aws-sdk/region-config-resolver": "3.893.0", - "@aws-sdk/types": "3.893.0", - "@aws-sdk/util-endpoints": "3.893.0", - "@aws-sdk/util-user-agent-browser": "3.893.0", - "@aws-sdk/util-user-agent-node": "3.893.0", - "@smithy/config-resolver": "^4.2.2", - "@smithy/core": "^3.11.1", - "@smithy/fetch-http-handler": "^5.2.1", - "@smithy/hash-node": "^4.1.1", - "@smithy/invalid-dependency": "^4.1.1", - "@smithy/middleware-content-length": "^4.1.1", - "@smithy/middleware-endpoint": "^4.2.3", - "@smithy/middleware-retry": "^4.2.4", - "@smithy/middleware-serde": "^4.1.1", - "@smithy/middleware-stack": "^4.1.1", - "@smithy/node-config-provider": "^4.2.2", - "@smithy/node-http-handler": "^4.2.1", - "@smithy/protocol-http": "^5.2.1", - "@smithy/smithy-client": "^4.6.3", - "@smithy/types": "^4.5.0", - "@smithy/url-parser": "^4.1.1", - "@smithy/util-base64": "^4.1.0", - "@smithy/util-body-length-browser": "^4.1.0", - "@smithy/util-body-length-node": "^4.1.0", - "@smithy/util-defaults-mode-browser": "^4.1.3", - "@smithy/util-defaults-mode-node": "^4.1.3", - "@smithy/util-endpoints": "^3.1.2", - "@smithy/util-middleware": "^4.1.1", - "@smithy/util-retry": "^4.1.2", - "@smithy/util-utf8": "^4.1.0", + "@aws-sdk/core": "3.908.0", + "@aws-sdk/middleware-host-header": "3.901.0", + "@aws-sdk/middleware-logger": "3.901.0", + "@aws-sdk/middleware-recursion-detection": "3.901.0", + "@aws-sdk/middleware-user-agent": "3.908.0", + "@aws-sdk/region-config-resolver": "3.901.0", + "@aws-sdk/types": "3.901.0", + "@aws-sdk/util-endpoints": "3.901.0", + "@aws-sdk/util-user-agent-browser": "3.907.0", + "@aws-sdk/util-user-agent-node": "3.908.0", + "@smithy/config-resolver": "^4.3.0", + "@smithy/core": "^3.15.0", + "@smithy/fetch-http-handler": "^5.3.1", + "@smithy/hash-node": "^4.2.0", + "@smithy/invalid-dependency": "^4.2.0", + "@smithy/middleware-content-length": "^4.2.0", + "@smithy/middleware-endpoint": "^4.3.1", + "@smithy/middleware-retry": "^4.4.1", + "@smithy/middleware-serde": "^4.2.0", + "@smithy/middleware-stack": "^4.2.0", + "@smithy/node-config-provider": "^4.3.0", + "@smithy/node-http-handler": "^4.3.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/smithy-client": "^4.7.1", + "@smithy/types": "^4.6.0", + "@smithy/url-parser": "^4.2.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.0", + "@smithy/util-defaults-mode-node": "^4.2.1", + "@smithy/util-endpoints": "^3.2.0", + "@smithy/util-middleware": "^4.2.0", + "@smithy/util-retry": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -821,16 +818,16 @@ } }, "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/region-config-resolver": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.893.0.tgz", - "integrity": "sha512-/cJvh3Zsa+Of0Zbg7vl9wp/kZtdb40yk/2+XcroAMVPO9hPvmS9r/UOm6tO7FeX4TtkRFwWaQJiTZTgSdsPY+Q==", + "version": "3.901.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.901.0.tgz", + "integrity": "sha512-7F0N888qVLHo4CSQOsnkZ4QAp8uHLKJ4v3u09Ly5k4AEStrSlFpckTPyUx6elwGL+fxGjNE2aakK8vEgzzCV0A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.893.0", - "@smithy/node-config-provider": "^4.2.2", - "@smithy/types": "^4.5.0", - "@smithy/util-config-provider": "^4.1.0", - "@smithy/util-middleware": "^4.1.1", + "@aws-sdk/types": "3.901.0", + "@smithy/node-config-provider": "^4.3.0", + "@smithy/types": "^4.6.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-middleware": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -838,16 +835,16 @@ } }, "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.893.0.tgz", - "integrity": "sha512-pp4Bn8dL4i68P/mHgZ7sgkm8OSIpwjtGxP73oGseu9Cli0JRyJ1asTSsT60hUz3sbo+3oKk3hEobD6UxLUeGRA==", + "version": "3.908.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.908.0.tgz", + "integrity": "sha512-8OodflIzZM2GVuCGiGK6hqwsbfHRDl4kQcEYzHRg9p91H4h5Y876DPvLRkwM7pSC7LKUL0XkKWWVVjwJbp6/Ig==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-sdk-s3": "3.893.0", - "@aws-sdk/types": "3.893.0", - "@smithy/protocol-http": "^5.2.1", - "@smithy/signature-v4": "^5.2.1", - "@smithy/types": "^4.5.0", + "@aws-sdk/middleware-sdk-s3": "3.908.0", + "@aws-sdk/types": "3.901.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/signature-v4": "^5.3.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -855,17 +852,17 @@ } }, "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/token-providers": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.893.0.tgz", - "integrity": "sha512-nkzuE910TxW4pnIwJ+9xDMx5m+A8iXGM16Oa838YKsds07cgkRp7sPnpH9B8NbGK2szskAAkXfj7t1f59EKd1Q==", + "version": "3.908.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.908.0.tgz", + "integrity": "sha512-4SosHWRQ8hj1X2yDenCYHParcCjHcd7S+Mdb/lelwF0JBFCNC+dNCI9ws3cP/dFdZO/AIhJQGUBzEQtieloixw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.893.0", - "@aws-sdk/nested-clients": "3.893.0", - "@aws-sdk/types": "3.893.0", - "@smithy/property-provider": "^4.1.1", - "@smithy/shared-ini-file-loader": "^4.2.0", - "@smithy/types": "^4.5.0", + "@aws-sdk/core": "3.908.0", + "@aws-sdk/nested-clients": "3.908.0", + "@aws-sdk/types": "3.901.0", + "@smithy/property-provider": "^4.2.0", + "@smithy/shared-ini-file-loader": "^4.3.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -873,12 +870,12 @@ } }, "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/types": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.893.0.tgz", - "integrity": "sha512-Aht1nn5SnA0N+Tjv0dzhAY7CQbxVtmq1bBR6xI0MhG7p2XYVh1wXuKTzrldEvQWwA3odOYunAfT9aBiKZx9qIg==", + "version": "3.901.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.901.0.tgz", + "integrity": "sha512-FfEM25hLEs4LoXsLXQ/q6X6L4JmKkKkbVFpKD4mwfVHtRVQG6QxJiCPcrkcPISquiy6esbwK2eh64TWbiD60cg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -898,15 +895,15 @@ } }, "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/util-endpoints": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.893.0.tgz", - "integrity": "sha512-xeMcL31jXHKyxRwB3oeNjs8YEpyvMnSYWr2OwLydgzgTr0G349AHlJHwYGCF9xiJ2C27kDxVvXV/Hpdp0p7TWw==", + "version": "3.901.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.901.0.tgz", + "integrity": "sha512-5nZP3hGA8FHEtKvEQf4Aww5QZOkjLW1Z+NixSd+0XKfHvA39Ah5sZboScjLx0C9kti/K3OGW1RCx5K9Zc3bZqg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.893.0", - "@smithy/types": "^4.5.0", - "@smithy/url-parser": "^4.1.1", - "@smithy/util-endpoints": "^3.1.2", + "@aws-sdk/types": "3.901.0", + "@smithy/types": "^4.6.0", + "@smithy/url-parser": "^4.2.0", + "@smithy/util-endpoints": "^3.2.0", "tslib": "^2.6.2" }, "engines": { @@ -914,27 +911,27 @@ } }, "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.893.0.tgz", - "integrity": "sha512-PE9NtbDBW6Kgl1bG6A5fF3EPo168tnkj8TgMcT0sg4xYBWsBpq0bpJZRh+Jm5Bkwiw9IgTCLjEU7mR6xWaMB9w==", + "version": "3.907.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.907.0.tgz", + "integrity": "sha512-Hus/2YCQmtCEfr4Ls88d07Q99Ex59uvtktiPTV963Q7w7LHuIT/JBjrbwNxtSm2KlJR9PHNdqxwN+fSuNsMGMQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.893.0", - "@smithy/types": "^4.5.0", + "@aws-sdk/types": "3.901.0", + "@smithy/types": "^4.6.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.893.0.tgz", - "integrity": "sha512-tTRkJo/fth9NPJ2AO/XLuJWVsOhbhejQRLyP0WXG3z0Waa5IWK5YBxBC1tWWATUCwsN748JQXU03C1aF9cRD9w==", + "version": "3.908.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.908.0.tgz", + "integrity": "sha512-l6AEaKUAYarcEy8T8NZ+dNZ00VGLs3fW2Cqu1AuPENaSad0/ahEU+VU7MpXS8FhMRGPgplxKVgCTLyTY0Lbssw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-user-agent": "3.893.0", - "@aws-sdk/types": "3.893.0", - "@smithy/node-config-provider": "^4.2.2", - "@smithy/types": "^4.5.0", + "@aws-sdk/middleware-user-agent": "3.908.0", + "@aws-sdk/types": "3.901.0", + "@smithy/node-config-provider": "^4.3.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -950,31 +947,19 @@ } }, "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/xml-builder": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.893.0.tgz", - "integrity": "sha512-qKkJ2E0hU60iq0o2+hBSIWS8sf34xhqiRRGw5nbRhwEnE2MsWsWBpRoysmr32uq9dHMWUzII0c/fS29+wOSdMA==", + "version": "3.901.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.901.0.tgz", + "integrity": "sha512-pxFCkuAP7Q94wMTNPAwi6hEtNrp/BdFf+HOrIEeFQsk4EoOmpKY3I6S+u6A9Wg295J80Kh74LqDWM22ux3z6Aw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", + "@smithy/types": "^4.6.0", + "fast-xml-parser": "5.2.5", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-s3/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/@aws-sdk/client-sesv2": { "version": "3.890.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sesv2/-/client-sesv2-3.890.0.tgz", @@ -1240,17 +1225,17 @@ } }, "node_modules/@aws-sdk/middleware-bucket-endpoint": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.893.0.tgz", - "integrity": "sha512-H+wMAoFC73T7M54OFIezdHXR9/lH8TZ3Cx1C3MEBb2ctlzQrVCd8LX8zmOtcGYC8plrRwV+8rNPe0FMqecLRew==", + "version": "3.901.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.901.0.tgz", + "integrity": "sha512-mPF3N6eZlVs9G8aBSzvtoxR1RZqMo1aIwR+X8BAZSkhfj55fVF2no4IfPXfdFO3I66N+zEQ8nKoB0uTATWrogQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.893.0", + "@aws-sdk/types": "3.901.0", "@aws-sdk/util-arn-parser": "3.893.0", - "@smithy/node-config-provider": "^4.2.2", - "@smithy/protocol-http": "^5.2.1", - "@smithy/types": "^4.5.0", - "@smithy/util-config-provider": "^4.1.0", + "@smithy/node-config-provider": "^4.3.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/types": "^4.6.0", + "@smithy/util-config-provider": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -1258,12 +1243,12 @@ } }, "node_modules/@aws-sdk/middleware-bucket-endpoint/node_modules/@aws-sdk/types": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.893.0.tgz", - "integrity": "sha512-Aht1nn5SnA0N+Tjv0dzhAY7CQbxVtmq1bBR6xI0MhG7p2XYVh1wXuKTzrldEvQWwA3odOYunAfT9aBiKZx9qIg==", + "version": "3.901.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.901.0.tgz", + "integrity": "sha512-FfEM25hLEs4LoXsLXQ/q6X6L4JmKkKkbVFpKD4mwfVHtRVQG6QxJiCPcrkcPISquiy6esbwK2eh64TWbiD60cg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -1283,14 +1268,14 @@ } }, "node_modules/@aws-sdk/middleware-expect-continue": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.893.0.tgz", - "integrity": "sha512-PEZkvD6k0X9sacHkvkVF4t2QyQEAzd35OJ2bIrjWCfc862TwukMMJ1KErRmQ1WqKXHKF4L0ed5vtWaO/8jVLNA==", + "version": "3.901.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.901.0.tgz", + "integrity": "sha512-bwq9nj6MH38hlJwOY9QXIDwa6lI48UsaZpaXbdD71BljEIRlxDzfB4JaYb+ZNNK7RIAdzsP/K05mJty6KJAQHw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.893.0", - "@smithy/protocol-http": "^5.2.1", - "@smithy/types": "^4.5.0", + "@aws-sdk/types": "3.901.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -1298,12 +1283,12 @@ } }, "node_modules/@aws-sdk/middleware-expect-continue/node_modules/@aws-sdk/types": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.893.0.tgz", - "integrity": "sha512-Aht1nn5SnA0N+Tjv0dzhAY7CQbxVtmq1bBR6xI0MhG7p2XYVh1wXuKTzrldEvQWwA3odOYunAfT9aBiKZx9qIg==", + "version": "3.901.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.901.0.tgz", + "integrity": "sha512-FfEM25hLEs4LoXsLXQ/q6X6L4JmKkKkbVFpKD4mwfVHtRVQG6QxJiCPcrkcPISquiy6esbwK2eh64TWbiD60cg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -1311,23 +1296,23 @@ } }, "node_modules/@aws-sdk/middleware-flexible-checksums": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.893.0.tgz", - "integrity": "sha512-2swRPpyGK6xpZwIFmmFSFKp10iuyBLZEouhrt1ycBVA8iHGmPkuJSCim6Vb+JoRKqINp5tizWeQwdg9boIxJPw==", + "version": "3.908.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.908.0.tgz", + "integrity": "sha512-hYGhNBvdfnxhhywYRkesdxIZD8rvhsp2CBci5kCqrR2o5VvEkn5+waUQtkREtkciEpC4ent4fadg7N9XfTKvgQ==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", - "@aws-sdk/core": "3.893.0", - "@aws-sdk/types": "3.893.0", - "@smithy/is-array-buffer": "^4.1.0", - "@smithy/node-config-provider": "^4.2.2", - "@smithy/protocol-http": "^5.2.1", - "@smithy/types": "^4.5.0", - "@smithy/util-middleware": "^4.1.1", - "@smithy/util-stream": "^4.3.2", - "@smithy/util-utf8": "^4.1.0", + "@aws-sdk/core": "3.908.0", + "@aws-sdk/types": "3.901.0", + "@smithy/is-array-buffer": "^4.2.0", + "@smithy/node-config-provider": "^4.3.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/types": "^4.6.0", + "@smithy/util-middleware": "^4.2.0", + "@smithy/util-stream": "^4.5.0", + "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -1335,25 +1320,23 @@ } }, "node_modules/@aws-sdk/middleware-flexible-checksums/node_modules/@aws-sdk/core": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.893.0.tgz", - "integrity": "sha512-E1NAWHOprBXIJ9CVb6oTsRD/tNOozrKBD/Sb4t7WZd3dpby6KpYfM6FaEGfRGcJBIcB4245hww8Rmg16qDMJWg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.893.0", - "@aws-sdk/xml-builder": "3.893.0", - "@smithy/core": "^3.11.1", - "@smithy/node-config-provider": "^4.2.2", - "@smithy/property-provider": "^4.1.1", - "@smithy/protocol-http": "^5.2.1", - "@smithy/signature-v4": "^5.2.1", - "@smithy/smithy-client": "^4.6.3", - "@smithy/types": "^4.5.0", - "@smithy/util-base64": "^4.1.0", - "@smithy/util-body-length-browser": "^4.1.0", - "@smithy/util-middleware": "^4.1.1", - "@smithy/util-utf8": "^4.1.0", - "fast-xml-parser": "5.2.5", + "version": "3.908.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.908.0.tgz", + "integrity": "sha512-okl6FC2cQT1Oidvmnmvyp/IEvqENBagKO0ww4YV5UtBkf0VlhAymCWkZqhovtklsqgq0otag2VRPAgnrMt6nVQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.901.0", + "@aws-sdk/xml-builder": "3.901.0", + "@smithy/core": "^3.15.0", + "@smithy/node-config-provider": "^4.3.0", + "@smithy/property-provider": "^4.2.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/signature-v4": "^5.3.0", + "@smithy/smithy-client": "^4.7.1", + "@smithy/types": "^4.6.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-middleware": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -1361,12 +1344,12 @@ } }, "node_modules/@aws-sdk/middleware-flexible-checksums/node_modules/@aws-sdk/types": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.893.0.tgz", - "integrity": "sha512-Aht1nn5SnA0N+Tjv0dzhAY7CQbxVtmq1bBR6xI0MhG7p2XYVh1wXuKTzrldEvQWwA3odOYunAfT9aBiKZx9qIg==", + "version": "3.901.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.901.0.tgz", + "integrity": "sha512-FfEM25hLEs4LoXsLXQ/q6X6L4JmKkKkbVFpKD4mwfVHtRVQG6QxJiCPcrkcPISquiy6esbwK2eh64TWbiD60cg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -1374,12 +1357,13 @@ } }, "node_modules/@aws-sdk/middleware-flexible-checksums/node_modules/@aws-sdk/xml-builder": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.893.0.tgz", - "integrity": "sha512-qKkJ2E0hU60iq0o2+hBSIWS8sf34xhqiRRGw5nbRhwEnE2MsWsWBpRoysmr32uq9dHMWUzII0c/fS29+wOSdMA==", + "version": "3.901.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.901.0.tgz", + "integrity": "sha512-pxFCkuAP7Q94wMTNPAwi6hEtNrp/BdFf+HOrIEeFQsk4EoOmpKY3I6S+u6A9Wg295J80Kh74LqDWM22ux3z6Aw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", + "@smithy/types": "^4.6.0", + "fast-xml-parser": "5.2.5", "tslib": "^2.6.2" }, "engines": { @@ -1402,13 +1386,13 @@ } }, "node_modules/@aws-sdk/middleware-location-constraint": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.893.0.tgz", - "integrity": "sha512-MlbBc7Ttb1ekbeeeFBU4DeEZOLb5s0Vl4IokvO17g6yJdLk4dnvZro9zdXl3e7NXK+kFxHRBFZe55p/42mVgDA==", + "version": "3.901.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.901.0.tgz", + "integrity": "sha512-MuCS5R2ngNoYifkVt05CTULvYVWX0dvRT0/Md4jE3a0u0yMygYy31C1zorwfE/SUgAQXyLmUx8ATmPp9PppImQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.893.0", - "@smithy/types": "^4.5.0", + "@aws-sdk/types": "3.901.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -1416,12 +1400,12 @@ } }, "node_modules/@aws-sdk/middleware-location-constraint/node_modules/@aws-sdk/types": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.893.0.tgz", - "integrity": "sha512-Aht1nn5SnA0N+Tjv0dzhAY7CQbxVtmq1bBR6xI0MhG7p2XYVh1wXuKTzrldEvQWwA3odOYunAfT9aBiKZx9qIg==", + "version": "3.901.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.901.0.tgz", + "integrity": "sha512-FfEM25hLEs4LoXsLXQ/q6X6L4JmKkKkbVFpKD4mwfVHtRVQG6QxJiCPcrkcPISquiy6esbwK2eh64TWbiD60cg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -1484,13 +1468,13 @@ } }, "node_modules/@aws-sdk/middleware-ssec": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.893.0.tgz", - "integrity": "sha512-e4ccCiAnczv9mMPheKjgKxZQN473mcup+3DPLVNnIw5GRbQoDqPSB70nUzfORKZvM7ar7xLMPxNR8qQgo1C8Rg==", + "version": "3.901.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.901.0.tgz", + "integrity": "sha512-YiLLJmA3RvjL38mFLuu8fhTTGWtp2qT24VqpucgfoyziYcTgIQkJJmKi90Xp6R6/3VcArqilyRgM1+x8i/em+Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.893.0", - "@smithy/types": "^4.5.0", + "@aws-sdk/types": "3.901.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -1498,12 +1482,12 @@ } }, "node_modules/@aws-sdk/middleware-ssec/node_modules/@aws-sdk/types": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.893.0.tgz", - "integrity": "sha512-Aht1nn5SnA0N+Tjv0dzhAY7CQbxVtmq1bBR6xI0MhG7p2XYVh1wXuKTzrldEvQWwA3odOYunAfT9aBiKZx9qIg==", + "version": "3.901.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.901.0.tgz", + "integrity": "sha512-FfEM25hLEs4LoXsLXQ/q6X6L4JmKkKkbVFpKD4mwfVHtRVQG6QxJiCPcrkcPISquiy6esbwK2eh64TWbiD60cg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -4160,12 +4144,12 @@ } }, "node_modules/@smithy/abort-controller": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.1.1.tgz", - "integrity": "sha512-vkzula+IwRvPR6oKQhMYioM3A/oX/lFCZiwuxkQbRhqJS2S4YRY2k7k/SyR2jMf3607HLtbEwlRxi0ndXHMjRg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.0.tgz", + "integrity": "sha512-PLUYa+SUKOEZtXFURBu/CNxlsxfaFGxSBPcStL13KpVeVWIfdezWyDqkz7iDLmwnxojXD0s5KzuB5HGHvt4Aeg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -4173,9 +4157,9 @@ } }, "node_modules/@smithy/chunked-blob-reader": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.1.0.tgz", - "integrity": "sha512-a36AtR7Q7XOhRPt6F/7HENmTWcB8kN7mDJcOFM/+FuKO6x88w8MQJfYCufMWh4fGyVkPjUh3Rrz/dnqFQdo6OQ==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.0.tgz", + "integrity": "sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -4185,12 +4169,12 @@ } }, "node_modules/@smithy/chunked-blob-reader-native": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.1.0.tgz", - "integrity": "sha512-Bnv0B3nSlfB2mPO0WgM49I/prl7+kamF042rrf3ezJ3Z4C7csPYvyYgZfXTGXwXfj1mAwDWjE/ybIf49PzFzvA==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.1.tgz", + "integrity": "sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/util-base64": "^4.1.0", + "@smithy/util-base64": "^4.3.0", "tslib": "^2.6.2" }, "engines": { @@ -4198,15 +4182,15 @@ } }, "node_modules/@smithy/config-resolver": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.2.2.tgz", - "integrity": "sha512-IT6MatgBWagLybZl1xQcURXRICvqz1z3APSCAI9IqdvfCkrA7RaQIEfgC6G/KvfxnDfQUDqFV+ZlixcuFznGBQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.3.0.tgz", + "integrity": "sha512-9oH+n8AVNiLPK/iK/agOsoWfrKZ3FGP3502tkksd6SRsKMYiu7AFX0YXo6YBADdsAj7C+G/aLKdsafIJHxuCkQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.2.2", - "@smithy/types": "^4.5.0", - "@smithy/util-config-provider": "^4.1.0", - "@smithy/util-middleware": "^4.1.1", + "@smithy/node-config-provider": "^4.3.0", + "@smithy/types": "^4.6.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-middleware": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -4214,50 +4198,36 @@ } }, "node_modules/@smithy/core": { - "version": "3.11.1", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.11.1.tgz", - "integrity": "sha512-REH7crwORgdjSpYs15JBiIWOYjj0hJNC3aCecpJvAlMMaaqL5i2CLb1i6Hc4yevToTKSqslLMI9FKjhugEwALA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/middleware-serde": "^4.1.1", - "@smithy/protocol-http": "^5.2.1", - "@smithy/types": "^4.5.0", - "@smithy/util-base64": "^4.1.0", - "@smithy/util-body-length-browser": "^4.1.0", - "@smithy/util-middleware": "^4.1.1", - "@smithy/util-stream": "^4.3.2", - "@smithy/util-utf8": "^4.1.0", - "@types/uuid": "^9.0.1", - "tslib": "^2.6.2", - "uuid": "^9.0.1" + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.15.0.tgz", + "integrity": "sha512-VJWncXgt+ExNn0U2+Y7UywuATtRYaodGQKFo9mDyh70q+fJGedfrqi2XuKU1BhiLeXgg6RZrW7VEKfeqFhHAJA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/middleware-serde": "^4.2.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/types": "^4.6.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-middleware": "^4.2.0", + "@smithy/util-stream": "^4.5.0", + "@smithy/util-utf8": "^4.2.0", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/core/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.1.2.tgz", - "integrity": "sha512-JlYNq8TShnqCLg0h+afqe2wLAwZpuoSgOyzhYvTgbiKBWRov+uUve+vrZEQO6lkdLOWPh7gK5dtb9dS+KGendg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.0.tgz", + "integrity": "sha512-SOhFVvFH4D5HJZytb0bLKxCrSnwcqPiNlrw+S4ZXjMnsC+o9JcUQzbZOEQcA8yv9wJFNhfsUiIUKiEnYL68Big==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.2.2", - "@smithy/property-provider": "^4.1.1", - "@smithy/types": "^4.5.0", - "@smithy/url-parser": "^4.1.1", + "@smithy/node-config-provider": "^4.3.0", + "@smithy/property-provider": "^4.2.0", + "@smithy/types": "^4.6.0", + "@smithy/url-parser": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -4265,14 +4235,14 @@ } }, "node_modules/@smithy/eventstream-codec": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.1.1.tgz", - "integrity": "sha512-PwkQw1hZwHTQB6X5hSUWz2OSeuj5Z6enWuAqke7DgWoP3t6vg3ktPpqPz3Erkn6w+tmsl8Oss6nrgyezoea2Iw==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.0.tgz", + "integrity": "sha512-XE7CtKfyxYiNZ5vz7OvyTf1osrdbJfmUy+rbh+NLQmZumMGvY0mT0Cq1qKSfhrvLtRYzMsOBuRpi10dyI0EBPg==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.5.0", - "@smithy/util-hex-encoding": "^4.1.0", + "@smithy/types": "^4.6.0", + "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -4280,13 +4250,13 @@ } }, "node_modules/@smithy/eventstream-serde-browser": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.1.1.tgz", - "integrity": "sha512-Q9QWdAzRaIuVkefupRPRFAasaG/droBqn1feiMnmLa+LLEUG45pqX1+FurHFmlqiCfobB3nUlgoJfeXZsr7MPA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.0.tgz", + "integrity": "sha512-U53p7fcrk27k8irLhOwUu+UYnBqsXNLKl1XevOpsxK3y1Lndk8R7CSiZV6FN3fYFuTPuJy5pP6qa/bjDzEkRvA==", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-serde-universal": "^4.1.1", - "@smithy/types": "^4.5.0", + "@smithy/eventstream-serde-universal": "^4.2.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -4294,12 +4264,12 @@ } }, "node_modules/@smithy/eventstream-serde-config-resolver": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.2.1.tgz", - "integrity": "sha512-oSUkF9zDN9zcOUBMtxp8RewJlh71E9NoHWU8jE3hU9JMYCsmW4assVTpgic/iS3/dM317j6hO5x18cc3XrfvEw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.0.tgz", + "integrity": "sha512-uwx54t8W2Yo9Jr3nVF5cNnkAAnMCJ8Wrm+wDlQY6rY/IrEgZS3OqagtCu/9ceIcZFQ1zVW/zbN9dxb5esuojfA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -4307,13 +4277,13 @@ } }, "node_modules/@smithy/eventstream-serde-node": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.1.1.tgz", - "integrity": "sha512-tn6vulwf/ScY0vjhzptSJuDJJqlhNtUjkxJ4wiv9E3SPoEqTEKbaq6bfqRO7nvhTG29ALICRcvfFheOUPl8KNA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.0.tgz", + "integrity": "sha512-yjM2L6QGmWgJjVu/IgYd6hMzwm/tf4VFX0lm8/SvGbGBwc+aFl3hOzvO/e9IJ2XI+22Tx1Zg3vRpFRs04SWFcg==", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-serde-universal": "^4.1.1", - "@smithy/types": "^4.5.0", + "@smithy/eventstream-serde-universal": "^4.2.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -4321,13 +4291,13 @@ } }, "node_modules/@smithy/eventstream-serde-universal": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.1.1.tgz", - "integrity": "sha512-uLOAiM/Dmgh2CbEXQx+6/ssK7fbzFhd+LjdyFxXid5ZBCbLHTFHLdD/QbXw5aEDsLxQhgzDxLLsZhsftAYwHJA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.0.tgz", + "integrity": "sha512-C3jxz6GeRzNyGKhU7oV656ZbuHY93mrfkT12rmjDdZch142ykjn8do+VOkeRNjSGKw01p4g+hdalPYPhmMwk1g==", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-codec": "^4.1.1", - "@smithy/types": "^4.5.0", + "@smithy/eventstream-codec": "^4.2.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -4335,15 +4305,15 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.2.1.tgz", - "integrity": "sha512-5/3wxKNtV3wO/hk1is+CZUhL8a1yy/U+9u9LKQ9kZTkMsHaQjJhc3stFfiujtMnkITjzWfndGA2f7g9Uh9vKng==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.1.tgz", + "integrity": "sha512-3AvYYbB+Dv5EPLqnJIAgYw/9+WzeBiUYS8B+rU0pHq5NMQMvrZmevUROS4V2GAt0jEOn9viBzPLrZE+riTNd5Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.2.1", - "@smithy/querystring-builder": "^4.1.1", - "@smithy/types": "^4.5.0", - "@smithy/util-base64": "^4.1.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/querystring-builder": "^4.2.0", + "@smithy/types": "^4.6.0", + "@smithy/util-base64": "^4.3.0", "tslib": "^2.6.2" }, "engines": { @@ -4351,14 +4321,14 @@ } }, "node_modules/@smithy/hash-blob-browser": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.1.1.tgz", - "integrity": "sha512-avAtk++s1e/1VODf+rg7c9R2pB5G9y8yaJaGY4lPZI2+UIqVyuSDMikWjeWfBVmFZ3O7NpDxBbUCyGhThVUKWQ==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.1.tgz", + "integrity": "sha512-Os9cg1fTXMwuqbvjemELlf+HB5oEeVyZmYsTbAtDQBmjGyibjmbeeqcaw7xOJLIHrkH/u0wAYabNcN6FRTqMRg==", "license": "Apache-2.0", "dependencies": { - "@smithy/chunked-blob-reader": "^5.1.0", - "@smithy/chunked-blob-reader-native": "^4.1.0", - "@smithy/types": "^4.5.0", + "@smithy/chunked-blob-reader": "^5.2.0", + "@smithy/chunked-blob-reader-native": "^4.2.1", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -4366,14 +4336,14 @@ } }, "node_modules/@smithy/hash-node": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.1.1.tgz", - "integrity": "sha512-H9DIU9WBLhYrvPs9v4sYvnZ1PiAI0oc8CgNQUJ1rpN3pP7QADbTOUjchI2FB764Ub0DstH5xbTqcMJu1pnVqxA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.0.tgz", + "integrity": "sha512-ugv93gOhZGysTctZh9qdgng8B+xO0cj+zN0qAZ+Sgh7qTQGPOJbMdIuyP89KNfUyfAqFSNh5tMvC+h2uCpmTtA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", - "@smithy/util-buffer-from": "^4.1.0", - "@smithy/util-utf8": "^4.1.0", + "@smithy/types": "^4.6.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -4381,13 +4351,13 @@ } }, "node_modules/@smithy/hash-stream-node": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.1.1.tgz", - "integrity": "sha512-3ztT4pV0Moazs3JAYFdfKk11kYFDo4b/3R3+xVjIm6wY9YpJf+xfz+ocEnNKcWAdcmSMqi168i2EMaKmJHbJMA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.0.tgz", + "integrity": "sha512-8dELAuGv+UEjtzrpMeNBZc1sJhO8GxFVV/Yh21wE35oX4lOE697+lsMHBoUIFAUuYkTMIeu0EuJSEsH7/8Y+UQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", - "@smithy/util-utf8": "^4.1.0", + "@smithy/types": "^4.6.0", + "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -4395,12 +4365,12 @@ } }, "node_modules/@smithy/invalid-dependency": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.1.1.tgz", - "integrity": "sha512-1AqLyFlfrrDkyES8uhINRlJXmHA2FkG+3DY8X+rmLSqmFwk3DJnvhyGzyByPyewh2jbmV+TYQBEfngQax8IFGg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.0.tgz", + "integrity": "sha512-ZmK5X5fUPAbtvRcUPtk28aqIClVhbfcmfoS4M7UQBTnDdrNxhsrxYVv0ZEl5NaPSyExsPWqL4GsPlRvtlwg+2A==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -4408,9 +4378,9 @@ } }, "node_modules/@smithy/is-array-buffer": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.1.0.tgz", - "integrity": "sha512-ePTYUOV54wMogio+he4pBybe8fwg4sDvEVDBU8ZlHOZXbXK3/C0XfJgUCu6qAZcawv05ZhZzODGUerFBPsPUDQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz", + "integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -4420,13 +4390,13 @@ } }, "node_modules/@smithy/md5-js": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.1.1.tgz", - "integrity": "sha512-MvWXKK743BuHjr/hnWuT6uStdKEaoqxHAQUvbKJPPZM5ZojTNFI5D+47BoQfBE5RgGlRRty05EbWA+NXDv+hIA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.0.tgz", + "integrity": "sha512-LFEPniXGKRQArFmDQ3MgArXlClFJMsXDteuQQY8WG1/zzv6gVSo96+qpkuu1oJp4MZsKrwchY0cuAoPKzEbaNA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", - "@smithy/util-utf8": "^4.1.0", + "@smithy/types": "^4.6.0", + "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -4434,13 +4404,13 @@ } }, "node_modules/@smithy/middleware-content-length": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.1.1.tgz", - "integrity": "sha512-9wlfBBgTsRvC2JxLJxv4xDGNBrZuio3AgSl0lSFX7fneW2cGskXTYpFxCdRYD2+5yzmsiTuaAJD1Wp7gWt9y9w==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.0.tgz", + "integrity": "sha512-6ZAnwrXFecrA4kIDOcz6aLBhU5ih2is2NdcZtobBDSdSHtE9a+MThB5uqyK4XXesdOCvOcbCm2IGB95birTSOQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.2.1", - "@smithy/types": "^4.5.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -4448,18 +4418,18 @@ } }, "node_modules/@smithy/middleware-endpoint": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.2.3.tgz", - "integrity": "sha512-+1H5A28DeffRVrqmVmtqtRraEjoaC6JVap3xEQdVoBh2EagCVY7noPmcBcG4y7mnr9AJitR1ZAse2l+tEtK5vg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.3.1.tgz", + "integrity": "sha512-JtM4SjEgImLEJVXdsbvWHYiJ9dtuKE8bqLlvkvGi96LbejDL6qnVpVxEFUximFodoQbg0Gnkyff9EKUhFhVJFw==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.11.1", - "@smithy/middleware-serde": "^4.1.1", - "@smithy/node-config-provider": "^4.2.2", - "@smithy/shared-ini-file-loader": "^4.2.0", - "@smithy/types": "^4.5.0", - "@smithy/url-parser": "^4.1.1", - "@smithy/util-middleware": "^4.1.1", + "@smithy/core": "^3.15.0", + "@smithy/middleware-serde": "^4.2.0", + "@smithy/node-config-provider": "^4.3.0", + "@smithy/shared-ini-file-loader": "^4.3.0", + "@smithy/types": "^4.6.0", + "@smithy/url-parser": "^4.2.0", + "@smithy/util-middleware": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -4467,47 +4437,33 @@ } }, "node_modules/@smithy/middleware-retry": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.2.4.tgz", - "integrity": "sha512-amyqYQFewnAviX3yy/rI/n1HqAgfvUdkEhc04kDjxsngAUREKuOI24iwqQUirrj6GtodWmR4iO5Zeyl3/3BwWg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.2.2", - "@smithy/protocol-http": "^5.2.1", - "@smithy/service-error-classification": "^4.1.2", - "@smithy/smithy-client": "^4.6.3", - "@smithy/types": "^4.5.0", - "@smithy/util-middleware": "^4.1.1", - "@smithy/util-retry": "^4.1.2", - "@types/uuid": "^9.0.1", - "tslib": "^2.6.2", - "uuid": "^9.0.1" + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.1.tgz", + "integrity": "sha512-wXxS4ex8cJJteL0PPQmWYkNi9QKDWZIpsndr0wZI2EL+pSSvA/qqxXU60gBOJoIc2YgtZSWY/PE86qhKCCKP1w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/service-error-classification": "^4.2.0", + "@smithy/smithy-client": "^4.7.1", + "@smithy/types": "^4.6.0", + "@smithy/util-middleware": "^4.2.0", + "@smithy/util-retry": "^4.2.0", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/middleware-retry/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/@smithy/middleware-serde": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.1.1.tgz", - "integrity": "sha512-lh48uQdbCoj619kRouev5XbWhCwRKLmphAif16c4J6JgJ4uXjub1PI6RL38d3BLliUvSso6klyB/LTNpWSNIyg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.0.tgz", + "integrity": "sha512-rpTQ7D65/EAbC6VydXlxjvbifTf4IH+sADKg6JmAvhkflJO2NvDeyU9qsWUNBelJiQFcXKejUHWRSdmpJmEmiw==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.2.1", - "@smithy/types": "^4.5.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -4515,12 +4471,12 @@ } }, "node_modules/@smithy/middleware-stack": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.1.1.tgz", - "integrity": "sha512-ygRnniqNcDhHzs6QAPIdia26M7e7z9gpkIMUe/pK0RsrQ7i5MblwxY8078/QCnGq6AmlUUWgljK2HlelsKIb/A==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.0.tgz", + "integrity": "sha512-G5CJ//eqRd9OARrQu9MK1H8fNm2sMtqFh6j8/rPozhEL+Dokpvi1Og+aCixTuwDAGZUkJPk6hJT5jchbk/WCyg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -4528,14 +4484,14 @@ } }, "node_modules/@smithy/node-config-provider": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.2.2.tgz", - "integrity": "sha512-SYGTKyPvyCfEzIN5rD8q/bYaOPZprYUPD2f5g9M7OjaYupWOoQFYJ5ho+0wvxIRf471i2SR4GoiZ2r94Jq9h6A==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.0.tgz", + "integrity": "sha512-5QgHNuWdT9j9GwMPPJCKxy2KDxZ3E5l4M3/5TatSZrqYVoEiqQrDfAq8I6KWZw7RZOHtVtCzEPdYz7rHZixwcA==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.1.1", - "@smithy/shared-ini-file-loader": "^4.2.0", - "@smithy/types": "^4.5.0", + "@smithy/property-provider": "^4.2.0", + "@smithy/shared-ini-file-loader": "^4.3.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -4543,15 +4499,15 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.2.1.tgz", - "integrity": "sha512-REyybygHlxo3TJICPF89N2pMQSf+p+tBJqpVe1+77Cfi9HBPReNjTgtZ1Vg73exq24vkqJskKDpfF74reXjxfw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.3.0.tgz", + "integrity": "sha512-RHZ/uWCmSNZ8cneoWEVsVwMZBKy/8123hEpm57vgGXA3Irf/Ja4v9TVshHK2ML5/IqzAZn0WhINHOP9xl+Qy6Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.1.1", - "@smithy/protocol-http": "^5.2.1", - "@smithy/querystring-builder": "^4.1.1", - "@smithy/types": "^4.5.0", + "@smithy/abort-controller": "^4.2.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/querystring-builder": "^4.2.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -4559,12 +4515,12 @@ } }, "node_modules/@smithy/property-provider": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.1.1.tgz", - "integrity": "sha512-gm3ZS7DHxUbzC2wr8MUCsAabyiXY0gaj3ROWnhSx/9sPMc6eYLMM4rX81w1zsMaObj2Lq3PZtNCC1J6lpEY7zg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.0.tgz", + "integrity": "sha512-rV6wFre0BU6n/tx2Ztn5LdvEdNZ2FasQbPQmDOPfV9QQyDmsCkOAB0osQjotRCQg+nSKFmINhyda0D3AnjSBJw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -4572,12 +4528,12 @@ } }, "node_modules/@smithy/protocol-http": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.2.1.tgz", - "integrity": "sha512-T8SlkLYCwfT/6m33SIU/JOVGNwoelkrvGjFKDSDtVvAXj/9gOT78JVJEas5a+ETjOu4SVvpCstKgd0PxSu/aHw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.0.tgz", + "integrity": "sha512-6POSYlmDnsLKb7r1D3SVm7RaYW6H1vcNcTWGWrF7s9+2noNYvUsm7E4tz5ZQ9HXPmKn6Hb67pBDRIjrT4w/d7Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -4585,13 +4541,13 @@ } }, "node_modules/@smithy/querystring-builder": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.1.1.tgz", - "integrity": "sha512-J9b55bfimP4z/Jg1gNo+AT84hr90p716/nvxDkPGCD4W70MPms0h8KF50RDRgBGZeL83/u59DWNqJv6tEP/DHA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.0.tgz", + "integrity": "sha512-Q4oFD0ZmI8yJkiPPeGUITZj++4HHYCW3pYBYfIobUCkYpI6mbkzmG1MAQQ3lJYYWj3iNqfzOenUZu+jqdPQ16A==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", - "@smithy/util-uri-escape": "^4.1.0", + "@smithy/types": "^4.6.0", + "@smithy/util-uri-escape": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -4599,12 +4555,12 @@ } }, "node_modules/@smithy/querystring-parser": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.1.1.tgz", - "integrity": "sha512-63TEp92YFz0oQ7Pj9IuI3IgnprP92LrZtRAkE3c6wLWJxfy/yOPRt39IOKerVr0JS770olzl0kGafXlAXZ1vng==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.0.tgz", + "integrity": "sha512-BjATSNNyvVbQxOOlKse0b0pSezTWGMvA87SvoFoFlkRsKXVsN3bEtjCxvsNXJXfnAzlWFPaT9DmhWy1vn0sNEA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -4612,24 +4568,24 @@ } }, "node_modules/@smithy/service-error-classification": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.1.2.tgz", - "integrity": "sha512-Kqd8wyfmBWHZNppZSMfrQFpc3M9Y/kjyN8n8P4DqJJtuwgK1H914R471HTw7+RL+T7+kI1f1gOnL7Vb5z9+NgQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.0.tgz", + "integrity": "sha512-Ylv1ttUeKatpR0wEOMnHf1hXMktPUMObDClSWl2TpCVT4DwtJhCeighLzSLbgH3jr5pBNM0LDXT5yYxUvZ9WpA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0" + "@smithy/types": "^4.6.0" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.2.0.tgz", - "integrity": "sha512-OQTfmIEp2LLuWdxa8nEEPhZmiOREO6bcB6pjs0AySf4yiZhl6kMOfqmcwcY8BaBPX+0Tb+tG7/Ia/6mwpoZ7Pw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.3.0.tgz", + "integrity": "sha512-VCUPPtNs+rKWlqqntX0CbVvWyjhmX30JCtzO+s5dlzzxrvSfRh5SY0yxnkirvc1c80vdKQttahL71a9EsdolSQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -4637,18 +4593,18 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.2.1.tgz", - "integrity": "sha512-M9rZhWQLjlQVCCR37cSjHfhriGRN+FQ8UfgrYNufv66TJgk+acaggShl3KS5U/ssxivvZLlnj7QH2CUOKlxPyA==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.0.tgz", + "integrity": "sha512-MKNyhXEs99xAZaFhm88h+3/V+tCRDQ+PrDzRqL0xdDpq4gjxcMmf5rBA3YXgqZqMZ/XwemZEurCBQMfxZOWq/g==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.1.0", - "@smithy/protocol-http": "^5.2.1", - "@smithy/types": "^4.5.0", - "@smithy/util-hex-encoding": "^4.1.0", - "@smithy/util-middleware": "^4.1.1", - "@smithy/util-uri-escape": "^4.1.0", - "@smithy/util-utf8": "^4.1.0", + "@smithy/is-array-buffer": "^4.2.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/types": "^4.6.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-middleware": "^4.2.0", + "@smithy/util-uri-escape": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -4656,17 +4612,17 @@ } }, "node_modules/@smithy/smithy-client": { - "version": "4.6.3", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.6.3.tgz", - "integrity": "sha512-K27LqywsaqKz4jusdUQYJh/YP2VbnbdskZ42zG8xfV+eovbTtMc2/ZatLWCfSkW0PDsTUXlpvlaMyu8925HsOw==", + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.7.1.tgz", + "integrity": "sha512-WXVbiyNf/WOS/RHUoFMkJ6leEVpln5ojCjNBnzoZeMsnCg3A0BRhLK3WYc4V7PmYcYPZh9IYzzAg9XcNSzYxYQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.11.1", - "@smithy/middleware-endpoint": "^4.2.3", - "@smithy/middleware-stack": "^4.1.1", - "@smithy/protocol-http": "^5.2.1", - "@smithy/types": "^4.5.0", - "@smithy/util-stream": "^4.3.2", + "@smithy/core": "^3.15.0", + "@smithy/middleware-endpoint": "^4.3.1", + "@smithy/middleware-stack": "^4.2.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/types": "^4.6.0", + "@smithy/util-stream": "^4.5.0", "tslib": "^2.6.2" }, "engines": { @@ -4674,9 +4630,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.5.0.tgz", - "integrity": "sha512-RkUpIOsVlAwUIZXO1dsz8Zm+N72LClFfsNqf173catVlvRZiwPy0x2u0JLEA4byreOPKDZPGjmPDylMoP8ZJRg==", + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.6.0.tgz", + "integrity": "sha512-4lI9C8NzRPOv66FaY1LL1O/0v0aLVrq/mXP/keUa9mJOApEeae43LsLd2kZRUJw91gxOQfLIrV3OvqPgWz1YsA==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -4686,13 +4642,13 @@ } }, "node_modules/@smithy/url-parser": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.1.1.tgz", - "integrity": "sha512-bx32FUpkhcaKlEoOMbScvc93isaSiRM75pQ5IgIBaMkT7qMlIibpPRONyx/0CvrXHzJLpOn/u6YiDX2hcvs7Dg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.0.tgz", + "integrity": "sha512-AlBmD6Idav2ugmoAL6UtR6ItS7jU5h5RNqLMZC7QrLCoITA9NzIN3nx9GWi8g4z1pfWh2r9r96SX/jHiNwPJ9A==", "license": "Apache-2.0", "dependencies": { - "@smithy/querystring-parser": "^4.1.1", - "@smithy/types": "^4.5.0", + "@smithy/querystring-parser": "^4.2.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -4700,13 +4656,13 @@ } }, "node_modules/@smithy/util-base64": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.1.0.tgz", - "integrity": "sha512-RUGd4wNb8GeW7xk+AY5ghGnIwM96V0l2uzvs/uVHf+tIuVX2WSvynk5CxNoBCsM2rQRSZElAo9rt3G5mJ/gktQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.0.tgz", + "integrity": "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^4.1.0", - "@smithy/util-utf8": "^4.1.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -4714,9 +4670,9 @@ } }, "node_modules/@smithy/util-body-length-browser": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.1.0.tgz", - "integrity": "sha512-V2E2Iez+bo6bUMOTENPr6eEmepdY8Hbs+Uc1vkDKgKNA/brTJqOW/ai3JO1BGj9GbCeLqw90pbbH7HFQyFotGQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz", + "integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -4726,9 +4682,9 @@ } }, "node_modules/@smithy/util-body-length-node": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.1.0.tgz", - "integrity": "sha512-BOI5dYjheZdgR9XiEM3HJcEMCXSoqbzu7CzIgYrx0UtmvtC3tC2iDGpJLsSRFffUpy8ymsg2ARMP5fR8mtuUQQ==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz", + "integrity": "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -4738,12 +4694,12 @@ } }, "node_modules/@smithy/util-buffer-from": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.1.0.tgz", - "integrity": "sha512-N6yXcjfe/E+xKEccWEKzK6M+crMrlwaCepKja0pNnlSkm6SjAeLKKA++er5Ba0I17gvKfN/ThV+ZOx/CntKTVw==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz", + "integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.1.0", + "@smithy/is-array-buffer": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -4751,9 +4707,9 @@ } }, "node_modules/@smithy/util-config-provider": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.1.0.tgz", - "integrity": "sha512-swXz2vMjrP1ZusZWVTB/ai5gK+J8U0BWvP10v9fpcFvg+Xi/87LHvHfst2IgCs1i0v4qFZfGwCmeD/KNCdJZbQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz", + "integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -4763,15 +4719,14 @@ } }, "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.1.3.tgz", - "integrity": "sha512-5fm3i2laE95uhY6n6O6uGFxI5SVbqo3/RWEuS3YsT0LVmSZk+0eUqPhKd4qk0KxBRPaT5VNT/WEBUqdMyYoRgg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.0.tgz", + "integrity": "sha512-H4MAj8j8Yp19Mr7vVtGgi7noJjvjJbsKQJkvNnLlrIFduRFT5jq5Eri1k838YW7rN2g5FTnXpz5ktKVr1KVgPQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.1.1", - "@smithy/smithy-client": "^4.6.3", - "@smithy/types": "^4.5.0", - "bowser": "^2.11.0", + "@smithy/property-provider": "^4.2.0", + "@smithy/smithy-client": "^4.7.1", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -4779,17 +4734,17 @@ } }, "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.1.3.tgz", - "integrity": "sha512-lwnMzlMslZ9GJNt+/wVjz6+fe9Wp5tqR1xAyQn+iywmP+Ymj0F6NhU/KfHM5jhGPQchRSCcau5weKhFdLIM4cA==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.1.tgz", + "integrity": "sha512-PuDcgx7/qKEMzV1QFHJ7E4/MMeEjaA7+zS5UNcHCLPvvn59AeZQ0DSDGMpqC2xecfa/1cNGm4l8Ec/VxCuY7Ug==", "license": "Apache-2.0", "dependencies": { - "@smithy/config-resolver": "^4.2.2", - "@smithy/credential-provider-imds": "^4.1.2", - "@smithy/node-config-provider": "^4.2.2", - "@smithy/property-provider": "^4.1.1", - "@smithy/smithy-client": "^4.6.3", - "@smithy/types": "^4.5.0", + "@smithy/config-resolver": "^4.3.0", + "@smithy/credential-provider-imds": "^4.2.0", + "@smithy/node-config-provider": "^4.3.0", + "@smithy/property-provider": "^4.2.0", + "@smithy/smithy-client": "^4.7.1", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -4797,13 +4752,13 @@ } }, "node_modules/@smithy/util-endpoints": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.1.2.tgz", - "integrity": "sha512-+AJsaaEGb5ySvf1SKMRrPZdYHRYSzMkCoK16jWnIMpREAnflVspMIDeCVSZJuj+5muZfgGpNpijE3mUNtjv01Q==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.0.tgz", + "integrity": "sha512-TXeCn22D56vvWr/5xPqALc9oO+LN+QpFjrSM7peG/ckqEPoI3zaKZFp+bFwfmiHhn5MGWPaLCqDOJPPIixk9Wg==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.2.2", - "@smithy/types": "^4.5.0", + "@smithy/node-config-provider": "^4.3.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -4811,9 +4766,9 @@ } }, "node_modules/@smithy/util-hex-encoding": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.1.0.tgz", - "integrity": "sha512-1LcueNN5GYC4tr8mo14yVYbh/Ur8jHhWOxniZXii+1+ePiIbsLZ5fEI0QQGtbRRP5mOhmooos+rLmVASGGoq5w==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz", + "integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -4823,12 +4778,12 @@ } }, "node_modules/@smithy/util-middleware": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.1.1.tgz", - "integrity": "sha512-CGmZ72mL29VMfESz7S6dekqzCh8ZISj3B+w0g1hZFXaOjGTVaSqfAEFAq8EGp8fUL+Q2l8aqNmt8U1tglTikeg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.0.tgz", + "integrity": "sha512-u9OOfDa43MjagtJZ8AapJcmimP+K2Z7szXn8xbty4aza+7P1wjFmy2ewjSbhEiYQoW1unTlOAIV165weYAaowA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -4836,13 +4791,13 @@ } }, "node_modules/@smithy/util-retry": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.1.2.tgz", - "integrity": "sha512-NCgr1d0/EdeP6U5PSZ9Uv5SMR5XRRYoVr1kRVtKZxWL3tixEL3UatrPIMFZSKwHlCcp2zPLDvMubVDULRqeunA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.0.tgz", + "integrity": "sha512-BWSiuGbwRnEE2SFfaAZEX0TqaxtvtSYPM/J73PFVm+A29Fg1HTPiYFb8TmX1DXp4hgcdyJcNQmprfd5foeORsg==", "license": "Apache-2.0", "dependencies": { - "@smithy/service-error-classification": "^4.1.2", - "@smithy/types": "^4.5.0", + "@smithy/service-error-classification": "^4.2.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -4850,18 +4805,18 @@ } }, "node_modules/@smithy/util-stream": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.3.2.tgz", - "integrity": "sha512-Ka+FA2UCC/Q1dEqUanCdpqwxOFdf5Dg2VXtPtB1qxLcSGh5C1HdzklIt18xL504Wiy9nNUKwDMRTVCbKGoK69g==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.0.tgz", + "integrity": "sha512-0TD5M5HCGu5diEvZ/O/WquSjhJPasqv7trjoqHyWjNh/FBeBl7a0ztl9uFMOsauYtRfd8jvpzIAQhDHbx+nvZw==", "license": "Apache-2.0", "dependencies": { - "@smithy/fetch-http-handler": "^5.2.1", - "@smithy/node-http-handler": "^4.2.1", - "@smithy/types": "^4.5.0", - "@smithy/util-base64": "^4.1.0", - "@smithy/util-buffer-from": "^4.1.0", - "@smithy/util-hex-encoding": "^4.1.0", - "@smithy/util-utf8": "^4.1.0", + "@smithy/fetch-http-handler": "^5.3.1", + "@smithy/node-http-handler": "^4.3.0", + "@smithy/types": "^4.6.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -4869,9 +4824,9 @@ } }, "node_modules/@smithy/util-uri-escape": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.1.0.tgz", - "integrity": "sha512-b0EFQkq35K5NHUYxU72JuoheM6+pytEVUGlTwiFxWFpmddA+Bpz3LgsPRIpBk8lnPE47yT7AF2Egc3jVnKLuPg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz", + "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -4881,12 +4836,12 @@ } }, "node_modules/@smithy/util-utf8": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.1.0.tgz", - "integrity": "sha512-mEu1/UIXAdNYuBcyEPbjScKi/+MQVXNIuY/7Cm5XLIWe319kDrT5SizBE95jqtmEXoDbGoZxKLCMttdZdqTZKQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz", + "integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==", "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^4.1.0", + "@smithy/util-buffer-from": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -4894,13 +4849,25 @@ } }, "node_modules/@smithy/util-waiter": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.1.1.tgz", - "integrity": "sha512-PJBmyayrlfxM7nbqjomF4YcT1sApQwZio0NHSsT0EzhJqljRmvhzqZua43TyEs80nJk2Cn2FGPg/N8phH6KeCQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.0.tgz", + "integrity": "sha512-0Z+nxUU4/4T+SL8BCNN4ztKdQjToNvUYmkF1kXO5T7Yz3Gafzh0HeIG6mrkN8Fz3gn9hSyxuAT+6h4vM+iQSBQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.0", + "@smithy/types": "^4.6.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/uuid": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz", + "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.1.1", - "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, "engines": { @@ -5266,12 +5233,6 @@ "@types/superagent": "^8.1.0" } }, - "node_modules/@types/uuid": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", - "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", - "license": "MIT" - }, "node_modules/@types/validator": { "version": "13.15.3", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.3.tgz", diff --git a/package.json b/package.json index 4e9cc0f..c09651a 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand --config ./test/jest.json" }, "dependencies": { - "@aws-sdk/client-s3": "^3.893.0", + "@aws-sdk/client-s3": "^3.908.0", "@aws-sdk/client-sesv2": "^3.890.0", "@nestjs/common": "^11.1.6", "@nestjs/config": "^4.0.2", diff --git a/src/features/feed/comments/get-all-comments/v1/get-all-comments.spec.ts b/src/features/feed/comments/get-all-comments/v1/get-all-comments.spec.ts new file mode 100644 index 0000000..647b430 --- /dev/null +++ b/src/features/feed/comments/get-all-comments/v1/get-all-comments.spec.ts @@ -0,0 +1,73 @@ +import { generateTestJwt } from 'test/jwt-helper'; +import { createTestIdea, createTestUser } from 'test/test-helpers'; +import request from 'supertest'; + +describe('/v1/comment (GET)', () => { + it('should return 200 and all comments for an idea', async () => { + // pegar ideiaId - OK + // criar 3 usuarios, 1 sendo autor da ideia - OK + // demais usuarios devem ser os autores dos comentarios - OK + // criar 2 comentários - OK + // listar comentarios da ideia - OK + // verificar retorno, 200 OK, 2 comentarios - OK + + const user1 = await createTestUser( + 'testuser', + 'test@example.com', + 'password123', + ); + + const idea = await createTestIdea( + 'Test Idea Title', + 'This is the content of the test idea', + user1.userProfileId, + [], // no comments at creation + ); + + const user2 = await createTestUser( + 'testuser2', + 'test2@example.com', + 'password1234', + ); + const tokenUser2 = generateTestJwt({ + sub: user2.id, + userProfileId: user2.userProfileId, + }); + const user3 = await createTestUser( + 'testuser3', + 'test3@example.com', + 'password12345', + ); + const tokenUser3 = generateTestJwt({ + sub: user3.id, + userProfileId: user3.userProfileId, + }); + + const comment = await request(app.getHttpServer()) + .post('/v1/comment') + .set('Authorization', `Bearer ${tokenUser2}`) + .send({ + content: 'Comment', + author: user2.userProfileId, + ideaId: idea.id, + }); + + // 1 comentário do user3 + await request(app.getHttpServer()) + .post('/v1/comment') + .set('Authorization', `Bearer ${tokenUser3}`) + .send({ + content: 'Comment 2', + ideaId: idea.id, + }); + + const getComments = await request(app.getHttpServer()) + .get(`/v1/comment?ideaId=${idea.id}`) + + console.log('Ideas after comments:', idea) + console.log('Get comments: ', JSON.stringify(getComments.body, null, 2)); + expect(getComments.status).toEqual(200); + expect(getComments.body.data.length).toEqual(2); + + }); +}); diff --git a/test/module.ts b/test/module.ts index 5e24564..2aab436 100644 --- a/test/module.ts +++ b/test/module.ts @@ -42,7 +42,7 @@ export const clearDatabase = async (app: INestApplication) => { try { // Disable foreign key checks temporarily - await dataSource.query('SET session_replication_role = replica;'); + // await dataSource.query('SET session_replication_role = replica;'); // Get all table names from the database const tables = await dataSource.query(` @@ -74,7 +74,7 @@ export const clearDatabase = async (app: INestApplication) => { } // Re-enable foreign key checks - await dataSource.query('SET session_replication_role = DEFAULT;'); + // await dataSource.query('SET session_replication_role = DEFAULT;'); } catch (error) { console.warn('Error clearing database:', (error as Error).message); // Continue with tests even if clearing fails diff --git a/test/test-helpers.ts b/test/test-helpers.ts index edd9fc0..122b737 100644 --- a/test/test-helpers.ts +++ b/test/test-helpers.ts @@ -1,5 +1,8 @@ import { User } from 'src/entities/user.entity'; import { hashSync } from 'bcrypt'; +import { Idea } from 'src/entities/idea.entity'; +import { Comment } from 'src/entities/comment.entity'; +import { DeepPartial } from 'typeorm'; export async function createTestUser( username: string, @@ -29,3 +32,20 @@ export async function createTestUser( return user; } + +export async function createTestIdea( + title: string, + content: string, + authorId: string, + comments: DeepPartial[], +): Promise { + const idea = await ideaRepository.save({ + // ideaId: `test-idea-${Math.random().toString(36).substring(2,15)}`, + title, + content, + authorId, + comments: comments, + }); + + return idea; +} diff --git a/test/test-setup.ts b/test/test-setup.ts index ef9c25f..997c98e 100644 --- a/test/test-setup.ts +++ b/test/test-setup.ts @@ -17,6 +17,7 @@ import { Like } from 'src/entities/like.entity'; import { Link } from 'src/entities/link.entity'; import { Tag } from 'src/entities/tag.entity'; import { View } from 'src/entities/view.entity'; +import { Comment } from 'src/entities/comment.entity'; declare global { var app: INestApplication; From e4e977546722242c983b0bffaf9c0e6aa2b7d4c8 Mon Sep 17 00:00:00 2001 From: gabriel Date: Sat, 18 Oct 2025 15:04:03 -0300 Subject: [PATCH 02/10] tests: error handling for invalid ideaId --- .../v1/get-all-comments.controller.ts | 12 +++++++++ .../v1/get-all-comments.dto.ts | 2 +- .../v1/get-all-comments.spec.ts | 25 ++++++++++--------- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/src/features/feed/comments/get-all-comments/v1/get-all-comments.controller.ts b/src/features/feed/comments/get-all-comments/v1/get-all-comments.controller.ts index 167c552..7097732 100644 --- a/src/features/feed/comments/get-all-comments/v1/get-all-comments.controller.ts +++ b/src/features/feed/comments/get-all-comments/v1/get-all-comments.controller.ts @@ -16,12 +16,16 @@ import { import { Repository } from 'typeorm'; import { InjectRepository } from '@nestjs/typeorm'; import { Comment } from 'src/entities/comment.entity'; +import { IdeaNotFound } from 'src/shared/errors'; +import { Idea } from 'src/entities/idea.entity'; @Controller('/comment') export class GetAllCommentsController { constructor( @InjectRepository(Comment) private readonly commentRepository: Repository, + @InjectRepository(Idea) + private readonly ideaRepository: Repository, ) {} readonly logger = new Logger(this.constructor.name); @@ -32,6 +36,14 @@ export class GetAllCommentsController { @Request() req: RequestWithUserData, @Query() { quantity, page, ideaId, parentCommentId }: GetAllCommentsInput, ): Promise { + if (!ideaId) throw new IdeaNotFound(); + + const idea = await this.ideaRepository.findOne({ + where: { id: ideaId } + }); + + if (!idea) throw new IdeaNotFound(); + const qb = this.commentRepository .createQueryBuilder('comment') .leftJoinAndSelect('comment.author', 'author') diff --git a/src/features/feed/comments/get-all-comments/v1/get-all-comments.dto.ts b/src/features/feed/comments/get-all-comments/v1/get-all-comments.dto.ts index 8d0e7da..73b09b8 100644 --- a/src/features/feed/comments/get-all-comments/v1/get-all-comments.dto.ts +++ b/src/features/feed/comments/get-all-comments/v1/get-all-comments.dto.ts @@ -15,7 +15,7 @@ export class GetAllCommentsInput { @IsOptional() quantity: number = 10; - @IsUUID() + @IsUUID(undefined, { message: 'INVALID_IDEA_ID'}) ideaId: string; @IsOptional() diff --git a/src/features/feed/comments/get-all-comments/v1/get-all-comments.spec.ts b/src/features/feed/comments/get-all-comments/v1/get-all-comments.spec.ts index 647b430..a95ba9c 100644 --- a/src/features/feed/comments/get-all-comments/v1/get-all-comments.spec.ts +++ b/src/features/feed/comments/get-all-comments/v1/get-all-comments.spec.ts @@ -4,13 +4,6 @@ import request from 'supertest'; describe('/v1/comment (GET)', () => { it('should return 200 and all comments for an idea', async () => { - // pegar ideiaId - OK - // criar 3 usuarios, 1 sendo autor da ideia - OK - // demais usuarios devem ser os autores dos comentarios - OK - // criar 2 comentários - OK - // listar comentarios da ideia - OK - // verificar retorno, 200 OK, 2 comentarios - OK - const user1 = await createTestUser( 'testuser', 'test@example.com', @@ -61,13 +54,21 @@ describe('/v1/comment (GET)', () => { ideaId: idea.id, }); - const getComments = await request(app.getHttpServer()) + const response = await request(app.getHttpServer()) .get(`/v1/comment?ideaId=${idea.id}`) - console.log('Ideas after comments:', idea) - console.log('Get comments: ', JSON.stringify(getComments.body, null, 2)); - expect(getComments.status).toEqual(200); - expect(getComments.body.data.length).toEqual(2); + expect(response.status).toEqual(200); + expect(response.body.data.length).toEqual(2); + + }); + + it('should return 400 when idea id does not exist', async () => { + const ideaId = '1'; + const response = await request(app.getHttpServer()) + .get(`/v1/comment?ideaId=${ideaId}`) + expect(response.status).toBe(400); + expect(response.body.data).toBeUndefined(); + expect(response.body.message).toContain('INVALID_IDEA_ID'); }); }); From 96090b9a5c73d8f0141156df39bd5afaaed152d1 Mon Sep 17 00:00:00 2001 From: gabriel Date: Sat, 18 Oct 2025 15:11:38 -0300 Subject: [PATCH 03/10] tests: handle comments in idea not existent --- .../get-all-comments/v1/get-all-comments.spec.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/features/feed/comments/get-all-comments/v1/get-all-comments.spec.ts b/src/features/feed/comments/get-all-comments/v1/get-all-comments.spec.ts index a95ba9c..f762bad 100644 --- a/src/features/feed/comments/get-all-comments/v1/get-all-comments.spec.ts +++ b/src/features/feed/comments/get-all-comments/v1/get-all-comments.spec.ts @@ -62,7 +62,7 @@ describe('/v1/comment (GET)', () => { }); - it('should return 400 when idea id does not exist', async () => { + it('should return 400 when idea id is invalid', async () => { const ideaId = '1'; const response = await request(app.getHttpServer()) .get(`/v1/comment?ideaId=${ideaId}`) @@ -71,4 +71,13 @@ describe('/v1/comment (GET)', () => { expect(response.body.data).toBeUndefined(); expect(response.body.message).toContain('INVALID_IDEA_ID'); }); + + it('should return 404 when ideaId does not exist', async () => { + const ideaId = '1e9c20a4-3f8d-4b71-8c0e-92a15f0b6d2c'; + const response = await request(app.getHttpServer()) + .get(`/v1/comment?ideaId=${ideaId}`) + + expect(response.status).toBe(404); + expect(response.body.message).toContain('IDEA_NOT_FOUND'); + }); }); From 324bc4a0c29e9790fbd3fb768e6ede16c455d3f6 Mon Sep 17 00:00:00 2001 From: gabriel Date: Wed, 5 Nov 2025 20:44:52 -0300 Subject: [PATCH 04/10] refactor: merge main into GAMIX-144-tests --- .github/workflows/verify.yml | 6 +- .prettierrc | 5 +- .vscode/launch.json | 20 + .vscode/settings.json | 37 + docs/guidelines.md | 661 ------------------ env.example | 1 + eslint.config.mjs | 23 +- package-lock.json | 335 +++++++++ package.json | 4 + src/app.module.ts | 119 +++- src/entities/chat.entity.ts | 96 +++ src/entities/collaboration-request.entity.ts | 87 +++ src/entities/comment.entity.ts | 20 +- src/entities/follow.entity.ts | 35 +- src/entities/idea.entity.ts | 73 +- src/entities/image.entity.ts | 51 -- src/entities/like.entity.ts | 16 +- src/entities/link.entity.ts | 44 -- src/entities/notification.entity.ts | 119 ++++ .../{user-oauth.entity.ts => oauth.entity.ts} | 18 +- ...er-profile.entity.ts => profile.entity.ts} | 70 +- src/entities/project.entity.ts | 112 +++ src/entities/tag.entity.ts | 4 +- src/entities/user-code.entity.ts | 27 - src/entities/user.entity.ts | 37 +- src/entities/view.entity.ts | 12 +- .../v1/refresh-token.controller.ts | 4 +- .../refresh-token/v1/refresh-token.feature.md | 56 -- .../refresh-token/v1/refresh-token.spec.ts | 16 +- .../auth/signin/v1/signin.controller.ts | 59 +- src/features/auth/signin/v1/signin.dto.ts | 4 +- src/features/auth/signin/v1/signin.feature.md | 67 -- src/features/auth/signin/v1/signin.spec.ts | 80 ++- .../auth/signup/v1/signup.controller.ts | 31 +- src/features/auth/signup/v1/signup.dto.ts | 4 +- src/features/auth/signup/v1/signup.feature.md | 85 --- src/features/auth/signup/v1/signup.spec.ts | 7 +- .../v1/get-all-chats.controller.ts | 59 ++ .../get-all-chats/v1/get-all-chats.dto.ts | 25 + .../chat/get-chat/v1/get-chat.controller.ts | 43 ++ src/features/chat/get-chat/v1/get-chat.dto.ts | 6 + .../v1/send-message.controller.ts | 71 ++ .../chat/send-message/v1/send-message.dto.ts | 16 + .../start-chat/v1/start-chat.controller.ts | 81 +++ .../chat/start-chat/v1/start-chat.dto.ts | 9 + .../v1/collaboration-approval.controller.ts | 155 ++++ .../v1/collaboration-approval.dto.ts | 18 + .../v1/collaboration-request.controller.ts | 95 +++ .../v1/collaboration-request.dto.ts | 10 + .../get-collaboration-request.controller.ts | 54 ++ .../v1/get-collaboration-request.dto.ts | 14 + .../v1/create-comment.controller.ts | 2 +- .../v1/create-comment.feature.md | 50 -- .../create-comment/v1/create-comment.spec.ts | 14 +- .../v1/get-all-comments.feature.md | 49 -- .../v1/get-all-comments.spec.ts | 57 +- .../v1/get-comment-by-id.controller.ts | 40 ++ .../v1/get-comments-by-id.dto.ts | 6 + .../create-idea/v1/create-idea.controller.ts | 47 +- .../ideas/create-idea/v1/create-idea.dto.ts | 9 +- .../create-idea/v1/create-idea.feature.md | 63 -- .../delete-idea/v1/delete-idea.controller.ts | 2 - .../delete-idea/v1/delete-idea.feature.md | 45 -- .../v1/get-all-ideas.controller.ts | 18 +- .../get-all-ideas/v1/get-all-ideas.dto.ts | 4 + .../get-all-ideas/v1/get-all-ideas.feature.md | 53 -- .../v1/get-idea-by-id.controller.ts | 2 - .../v1/get-idea-by-id.feature.md | 43 -- .../record-view/v1/record-view.controller.ts | 4 +- .../record-view/v1/record-view.feature.md | 45 -- .../update-idea/v1/update-idea.controller.ts | 26 +- .../ideas/update-idea/v1/update-idea.dto.ts | 2 +- .../update-idea/v1/update-idea.feature.md | 59 -- .../toggle-like/v1/toggle-like.controller.ts | 6 +- .../toggle-like/v1/toggle-like.feature.md | 38 - .../v1/get-health-status.feature.md | 57 -- .../v1/get-all-notifications.controller.ts | 74 ++ .../v1/get-all-notifications.dto.ts | 41 ++ .../notifications/read/v1/read.controller.ts | 47 ++ .../notifications/read/v1/read.dto.ts | 8 + .../v1/delete-project.controller.ts | 54 ++ .../delete-project/v1/delete-project.dto.ts | 6 + .../v1/get-all-projects.controller.ts | 141 ++++ .../v1/get-all-projects.dto.ts | 29 + .../get-project/v1/get-project.controller.ts | 96 +++ .../project/get-project/v1/get-project.dto.ts | 6 + .../v1/remove-member.controller.ts | 76 ++ .../remove-member/v1/remove-member.dto.ts | 9 + .../v1/update-project.controller.ts | 105 +++ .../update-project/v1/update-project.dto.ts | 18 + .../delete-user/v1/delete-user.controller.ts | 8 +- .../delete-user/v1/delete-user.feature.md | 72 -- .../delete-user/v1/delete-user.spec.ts | 41 +- .../v1/get-all-users.controller.ts | 22 +- .../get-all-users/v1/get-all-users.dto.ts | 4 +- .../get-all-users/v1/get-all-users.feature.md | 82 --- .../get-all-users/v1/get-all-users.spec.ts | 26 +- .../v1/get-user-by-id.controller.ts | 16 +- .../v1/get-user-by-id.feature.md | 90 --- .../get-user-by-id/v1/get-user-by-id.spec.ts | 52 +- .../v1/toggle-follow.controller.ts | 60 +- .../toggle-follow/v1/toggle-follow.dto.ts | 7 +- .../toggle-follow/v1/toggle-follow.feature.md | 126 ---- .../toggle-follow/v1/toggle-follow.spec.ts | 80 +-- .../update-user/v1/update-user.controller.ts | 38 +- .../update-user/v1/update-user.dto.ts | 10 +- .../update-user/v1/update-user.feature.md | 202 ------ .../update-user/v1/update-user.spec.ts | 38 +- src/jwt/jwt.strategy.ts | 4 +- src/setup-application.ts | 3 + src/shared/errors.ts | 80 ++- .../filters/validation-exception.filter.ts | 45 ++ src/shared/gateways/chat.gateway.ts | 260 +++++++ src/shared/gateways/s3.gateway.ts | 2 +- src/shared/gateways/ses.gateway.ts | 2 +- ...orMessage.ts => get-error-message.util.ts} | 0 ...eArraySafe.ts => parse-array-safe.util.ts} | 0 .../utils/{safeSave.ts => safe-save.util.ts} | 2 +- test/module.ts | 14 + test/test-helpers.ts | 9 +- test/test-setup.ts | 44 +- 121 files changed, 3341 insertions(+), 2650 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json delete mode 100644 docs/guidelines.md create mode 100644 src/entities/chat.entity.ts create mode 100644 src/entities/collaboration-request.entity.ts delete mode 100644 src/entities/image.entity.ts delete mode 100644 src/entities/link.entity.ts create mode 100644 src/entities/notification.entity.ts rename src/entities/{user-oauth.entity.ts => oauth.entity.ts} (58%) rename src/entities/{user-profile.entity.ts => profile.entity.ts} (54%) create mode 100644 src/entities/project.entity.ts delete mode 100644 src/entities/user-code.entity.ts delete mode 100644 src/features/auth/refresh-token/v1/refresh-token.feature.md delete mode 100644 src/features/auth/signin/v1/signin.feature.md delete mode 100644 src/features/auth/signup/v1/signup.feature.md create mode 100644 src/features/chat/get-all-chats/v1/get-all-chats.controller.ts create mode 100644 src/features/chat/get-all-chats/v1/get-all-chats.dto.ts create mode 100644 src/features/chat/get-chat/v1/get-chat.controller.ts create mode 100644 src/features/chat/get-chat/v1/get-chat.dto.ts create mode 100644 src/features/chat/send-message/v1/send-message.controller.ts create mode 100644 src/features/chat/send-message/v1/send-message.dto.ts create mode 100644 src/features/chat/start-chat/v1/start-chat.controller.ts create mode 100644 src/features/chat/start-chat/v1/start-chat.dto.ts create mode 100644 src/features/collaboration-requests/collaboration-approval/v1/collaboration-approval.controller.ts create mode 100644 src/features/collaboration-requests/collaboration-approval/v1/collaboration-approval.dto.ts create mode 100644 src/features/collaboration-requests/collaboration-request/v1/collaboration-request.controller.ts create mode 100644 src/features/collaboration-requests/collaboration-request/v1/collaboration-request.dto.ts create mode 100644 src/features/collaboration-requests/get-collaboration-request/v1/get-collaboration-request.controller.ts create mode 100644 src/features/collaboration-requests/get-collaboration-request/v1/get-collaboration-request.dto.ts delete mode 100644 src/features/feed/comments/create-comment/v1/create-comment.feature.md delete mode 100644 src/features/feed/comments/get-all-comments/v1/get-all-comments.feature.md create mode 100644 src/features/feed/comments/get-comment-by-id/v1/get-comment-by-id.controller.ts create mode 100644 src/features/feed/comments/get-comment-by-id/v1/get-comments-by-id.dto.ts delete mode 100644 src/features/feed/ideas/create-idea/v1/create-idea.feature.md delete mode 100644 src/features/feed/ideas/delete-idea/v1/delete-idea.feature.md delete mode 100644 src/features/feed/ideas/get-all-ideas/v1/get-all-ideas.feature.md delete mode 100644 src/features/feed/ideas/get-idea-by-id/v1/get-idea-by-id.feature.md delete mode 100644 src/features/feed/ideas/record-view/v1/record-view.feature.md delete mode 100644 src/features/feed/ideas/update-idea/v1/update-idea.feature.md delete mode 100644 src/features/feed/toggle-like/v1/toggle-like.feature.md delete mode 100644 src/features/get-health-status/v1/get-health-status.feature.md create mode 100644 src/features/notifications/get-all-notifications/v1/get-all-notifications.controller.ts create mode 100644 src/features/notifications/get-all-notifications/v1/get-all-notifications.dto.ts create mode 100644 src/features/notifications/read/v1/read.controller.ts create mode 100644 src/features/notifications/read/v1/read.dto.ts create mode 100644 src/features/project/delete-project/v1/delete-project.controller.ts create mode 100644 src/features/project/delete-project/v1/delete-project.dto.ts create mode 100644 src/features/project/get-all-projects/v1/get-all-projects.controller.ts create mode 100644 src/features/project/get-all-projects/v1/get-all-projects.dto.ts create mode 100644 src/features/project/get-project/v1/get-project.controller.ts create mode 100644 src/features/project/get-project/v1/get-project.dto.ts create mode 100644 src/features/project/remove-member/v1/remove-member.controller.ts create mode 100644 src/features/project/remove-member/v1/remove-member.dto.ts create mode 100644 src/features/project/update-project/v1/update-project.controller.ts create mode 100644 src/features/project/update-project/v1/update-project.dto.ts delete mode 100644 src/features/user-management/delete-user/v1/delete-user.feature.md delete mode 100644 src/features/user-management/get-all-users/v1/get-all-users.feature.md delete mode 100644 src/features/user-management/get-user-by-id/v1/get-user-by-id.feature.md delete mode 100644 src/features/user-management/toggle-follow/v1/toggle-follow.feature.md delete mode 100644 src/features/user-management/update-user/v1/update-user.feature.md create mode 100644 src/shared/filters/validation-exception.filter.ts create mode 100644 src/shared/gateways/chat.gateway.ts rename src/shared/utils/{getErrorMessage.ts => get-error-message.util.ts} (100%) rename src/shared/utils/{parseArraySafe.ts => parse-array-safe.util.ts} (100%) rename src/shared/utils/{safeSave.ts => safe-save.util.ts} (100%) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 5ff1cc7..93a49d3 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -19,9 +19,9 @@ jobs: - name: install deps run: | npm ci - - name: npm audit - run: | - npm run audit + # - name: npm audit + # run: | + # npm run audit - name: check code style run: | npm run lint diff --git a/.prettierrc b/.prettierrc index dcb7279..6de9cff 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,4 +1,5 @@ { "singleQuote": true, - "trailingComma": "all" -} \ No newline at end of file + "trailingComma": "all", + "endOfLine": "lf" +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..4d9abf3 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Debug NestJS", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "start:debug"], + "autoAttachChildProcesses": true, + "restart": true, + "sourceMaps": true, + "stopOnEntry": false, + "console": "integratedTerminal" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..22fc045 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,37 @@ +{ + "[json]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.insertFinalNewline": true, + "editor.trimFinalNewlines": false + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit", + "source.fixAll": "explicit", + "source.organizeImports": "explicit" + } + }, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit", + "source.fixAll": "explicit", + "source.organizeImports": "explicit" + } + }, + "editor.insertFinalNewline": true, + "editor.trimFinalNewlines": false, + "editor.rulers": [80, 120], + "[javascript][typescript]": { + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit", + "source.fixAll": "explicit", + "source.organizeImports": "explicit" + } + }, + "discord.enabled": true +} diff --git a/docs/guidelines.md b/docs/guidelines.md deleted file mode 100644 index c43e7da..0000000 --- a/docs/guidelines.md +++ /dev/null @@ -1,661 +0,0 @@ -# Diretrizes de Desenvolvimento - Gamix Backend - -Este documento define as diretrizes de desenvolvimento para o backend da plataforma Gamix, uma comunidade que une amantes de jogos, desenvolvedores e entusiastas para criar projetos inovadores na área de jogos. - -## 1. Visão Geral da Arquitetura - -O backend do Gamix é construído com NestJS e segue uma arquitetura simples e organizada baseada em features, com foco na simplicidade e manutenibilidade. - -### Tecnologias Utilizadas -- **Framework**: NestJS -- **Linguagem**: TypeScript -- **Banco de Dados**: PostgreSQL -- **Autenticação**: JWT (JSON Web Token) -- **ORM**: TypeORM -- **Email**: AWS SES -- **Deploy**: Vercel - -## 2. Estrutura do Projeto - -### 2.1. Estrutura de Diretórios - -``` -src/ -├── entities/ # Entidades do banco de dados -│ ├── user.entity.ts -│ ├── user-code.entity.ts -│ ├── user-oauth.entity.ts -│ ├── user-profile.entity.ts -│ ├── follow.entity.ts -│ └── link.entity.ts -├── features/ # Features organizadas por funcionalidade -│ ├── auth/ # Autenticação -│ │ ├── signin/v1/ -│ │ ├── signup/v1/ -│ │ └── refresh-token/v1/ -│ ├── user-management/ # Gerenciamento de usuários -│ │ ├── get-all-users/v1/ -│ │ ├── get-user-by-id/v1/ -│ │ └── delete-user/v1/ -│ └── get-health-status/v1/ # Health check -├── jwt/ # Configurações JWT -│ ├── auth.guard.ts -│ ├── jwt.strategy.ts -│ └── constants.ts -├── shared/ # Código compartilhado -│ ├── decorators/ -│ │ ├── log-response-time.decorator.ts -│ │ └── protected.decorator.ts -│ ├── gateways/ -│ │ └── ses.gateway.ts -│ ├── utils/ -│ └── errors.ts -├── app.module.ts -├── main.ts -└── setup-application.ts -``` - -### 2.2. Padrão de Features - -Cada feature segue a estrutura: - -``` -src/features/[feature-name]/[subfeature]/v[version]/ -├── [name].controller.ts -├── [name].dto.ts -├── [name].spec.ts -└── [name].feature.md -``` - -**Exemplo:** -``` -src/features/auth/signin/v1/ -├── signin.controller.ts -├── signin.dto.ts -├── signin.spec.ts -└── signin.feature.md -``` - -## 3. Padrões de Desenvolvimento - -### 3.1. Imports - -**Regra Obrigatória:** Sempre usar imports absolutos começando com `src/` - -```typescript -// ✅ CORRETO -import { User } from 'src/entities/user.entity'; -import { LogResponseTime } from 'src/shared/decorators/log-response-time.decorator'; -import { Protected } from 'src/shared/decorators/protected.decorator'; - -// ❌ INCORRETO -import { User } from '../../../entities/user.entity'; -import { LogResponseTime } from '../../../../shared/decorators/log-response-time.decorator'; -``` - -### 3.2. Controllers - -**Responsabilidades:** -- Receber requisições HTTP -- Validar dados de entrada -- Executar lógica de negócio -- Retornar respostas padronizadas -- Implementar logging automático - -**Exemplo de Controller:** - -```typescript -// src/features/auth/signin/v1/signin.controller.ts -import { - BadRequestException, - Body, - Controller, - HttpCode, - HttpStatus, - Logger, - Post, - Version, -} from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { User } from 'src/entities/user.entity'; -import { SigninInput, SigninOutput } from './signin.dto'; -import { LogResponseTime } from 'src/shared/decorators/log-response-time.decorator'; - -@Controller('/auth') -export class SignInController { - readonly logger = new Logger(this.constructor.name); - - constructor( - @InjectRepository(User) - private readonly userRepository: Repository, - ) {} - - @Post('/signin') - @Version('1') - @HttpCode(HttpStatus.OK) - @LogResponseTime() - async signin(@Body() body: SigninInput): Promise { - // Lógica de autenticação - } -} -``` - -### 3.3. DTOs - -**Responsabilidades:** -- Validação de dados de entrada -- Transformação de dados -- Documentação da API - -**Exemplo de DTO:** - -```typescript -// src/features/auth/signin/v1/signin.dto.ts -import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; - -export class SigninInput { - @IsEmail() - @IsNotEmpty() - email: string; - - @IsString() - @IsNotEmpty() - password: string; -} - -export class SigninOutput { - accessToken: string; - refreshToken: string; - user: { - id: string; - username: string; - email: string; - verified: boolean; - }; -} -``` - -### 3.4. Entidades - -**Responsabilidades:** -- Definir estrutura do banco de dados -- Relacionamentos entre entidades -- Métodos de transformação (toDto) - -**Exemplo de Entidade:** - -```typescript -// src/entities/user.entity.ts -import { - Entity, - PrimaryGeneratedColumn, - Column, - OneToOne, - JoinColumn, - CreateDateColumn, - UpdateDateColumn, -} from 'typeorm'; -import { UserProfile } from './user-profile.entity'; - -@Entity('users') -export class User { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ length: 50 }) - username: string; - - @Column({ length: 320, unique: true }) - email: string; - - @Column({ length: 60 }) - password: string; - - @Column({ default: false }) - verified: boolean; - - @OneToOne(() => UserProfile, (profile) => profile.user, { - cascade: true, - onDelete: 'CASCADE', - }) - @JoinColumn({ name: 'user_profile_id' }) - userProfile: UserProfile; - - @CreateDateColumn() - created_at: Date; - - @UpdateDateColumn() - updated_at: Date; - - toDto(currentUserId?: string): UserDto { - const dto = new UserDto(); - dto.id = this.id; - dto.username = this.username; - dto.email = this.email; - dto.verified = this.verified; - dto.userProfile = this.userProfile?.toDto(currentUserId); - return dto; - } -} -``` - -## 4. Autenticação e Autorização - -### 4.1. JWT Strategy - -```typescript -// src/jwt/jwt.strategy.ts -import { Injectable } from '@nestjs/common'; -import { PassportStrategy } from '@nestjs/passport'; -import { ExtractJwt, Strategy } from 'passport-jwt'; - -export interface RequestWithUserData extends Request { - user: { - sub: string; - userProfileId: string; - }; -} - -@Injectable() -export class JwtStrategy extends PassportStrategy(Strategy) { - constructor() { - super({ - jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), - ignoreExpiration: false, - secretOrKey: process.env.JWT_SIGNING_KEY_SECRET, - }); - } - - async validate(payload: any) { - return { - sub: payload.sub, - userProfileId: payload.userProfileId, - }; - } -} -``` - -### 4.2. Guards - -```typescript -// src/jwt/auth.guard.ts -import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; - -@Injectable() -export class JwtAuthGuard implements CanActivate { - constructor(private reflector: Reflector) {} - - canActivate(context: ExecutionContext): boolean { - const isPublic = this.reflector.getAllAndOverride('isPublic', [ - context.getHandler(), - context.getClass(), - ]); - - if (isPublic) { - return true; - } - - // Lógica de verificação JWT - return true; - } -} -``` - -### 4.3. Decorators - -```typescript -// src/shared/decorators/protected.decorator.ts -import { SetMetadata } from '@nestjs/common'; - -export const Protected = () => SetMetadata('isProtected', true); -``` - -## 5. Tratamento de Erros - -### 5.1. Exceções Customizadas - -```typescript -// src/shared/errors.ts -import { HttpException, HttpStatus } from '@nestjs/common'; - -export class UserNotFound extends HttpException { - constructor() { - super( - { - message: 'User not found', - error: 'Not Found', - statusCode: HttpStatus.NOT_FOUND, - }, - HttpStatus.NOT_FOUND, - ); - } -} - -export class TooManyLoginAttempts extends HttpException { - constructor() { - super( - { - message: 'Too many login attempts. Account temporarily blocked.', - error: 'Too Many Requests', - statusCode: HttpStatus.TOO_MANY_REQUESTS, - }, - HttpStatus.TOO_MANY_REQUESTS, - ); - } -} -``` - -## 6. Logging e Observabilidade - -### 6.1. Decorator de Logging - -```typescript -// src/shared/decorators/log-response-time.decorator.ts -import { Logger } from '@nestjs/common'; - -export function LogResponseTime() { - return function ( - target: object, - propertyKey: string, - descriptor: PropertyDescriptor, - ) { - const originalMethod = descriptor.value as ( - ...args: unknown[] - ) => Promise; - const logger = new Logger(target.constructor.name); - - descriptor.value = async function ( - this: any, - ...args: unknown[] - ) { - const startTime = new Date().getTime(); - const params = args.length > 0 ? args[0] : {}; - - logger.log(`Starting ${propertyKey}`, params); - - try { - const result = (await originalMethod.apply(this, args)) as unknown; - const responseTime = new Date().getTime() - startTime; - - logger.log( - `Finished ${propertyKey}. Response time: ${responseTime}ms`, - ); - - return result; - } catch (error) { - const responseTime = new Date().getTime() - startTime; - - logger.error( - `Error in ${propertyKey}. Response time: ${responseTime}ms`, - error, - ); - - throw error; - } - }; - - return descriptor; - }; -} -``` - -## 7. Testes - -### 7.1. Configuração Jest - -```typescript -// jest.config.ts -import { Config } from 'jest'; - -const config: Config = { - moduleFileExtensions: ['js', 'json', 'ts'], - rootDir: '.', - testEnvironment: 'node', - testRegex: '.spec.ts$', - transform: { - '^.+\\.ts$': 'ts-jest', - }, - collectCoverageFrom: [ - 'src/**/*.ts', - '!src/**/*.spec.ts', - '!src/main.ts', - '!test/setup-application.ts', - ], - coverageDirectory: './coverage', - moduleNameMapper: { - '^src/(.*)$': '/src/$1', - }, -}; - -export default config; -``` - -### 7.2. Setup de Aplicação - -```typescript -// test/setup-application.ts -import { - INestApplication, - ValidationPipe, - VersioningType, -} from '@nestjs/common'; - -export function setupApplication(app: INestApplication): void { - app.enableVersioning({ - type: VersioningType.URI, - defaultVersion: '1', - }); - - app.useGlobalPipes( - new ValidationPipe({ - whitelist: true, - transform: true, - }), - ); -} -``` - -### 7.3. Exemplo de Teste E2E - -```typescript -// src/features/auth/signin/v1/signin.spec.ts -import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { AppModule } from 'src/app.module'; -import { User } from 'src/entities/user.entity'; -import { setupApplication } from 'test/setup-application'; -import * as request from 'supertest'; -import { DataSource, Repository } from 'typeorm'; -import { hashSync } from 'bcrypt'; - -describe('/v1/auth/signin (POST)', () => { - let app: INestApplication; - let userRepository: Repository; - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - app = moduleFixture.createNestApplication(); - setupApplication(app); - await app.init(); - - userRepository = app.get('UserRepository'); - await userRepository.clear(); - }); - - afterEach(async () => { - const dataSource = app.get(DataSource); - await dataSource.destroy(); - await app.close(); - }); - - it('should return 200 and token when credentials are valid', async () => { - const hashedPassword = await bcrypt.hash('password123', 10); - await userRepository.save({ - username: 'testuser', - email: 'test@example.com', - password: hashedPassword, - verified: true, - }); - - const response = await request(app.getHttpServer()) - .post('/v1/auth/signin') - .send({ - email: 'test@example.com', - password: 'password123', - }); - - expect(response.status).toEqual(200); - expect(response.body.accessToken).toBeDefined(); - expect(response.body.user).toBeDefined(); - }); -}); -``` - -## 8. Documentação de Features - -### 8.1. Arquivo .feature.md - -Cada feature deve ter um arquivo `.feature.md` documentando: - -```markdown -# Feature: Sign In - -## Descrição -Endpoint para autenticação de usuários na plataforma Gamix. - -## Endpoint -`POST /v1/auth/signin` - -## Request Body -```json -{ - "email": "user@example.com", - "password": "password123" -} -``` - -## Response -```json -{ - "accessToken": "jwt_token", - "refreshToken": "refresh_token", - "user": { - "id": "uuid", - "username": "username", - "email": "user@example.com", - "verified": true - } -} -``` - -## Regras de Negócio -- Máximo 5 tentativas de login -- Bloqueio temporário após 5 tentativas -- Verificação de email obrigatória -- Senha deve ser hash bcrypt - -## Testes -- ✅ Login com credenciais válidas -- ✅ Login com credenciais inválidas -- ✅ Bloqueio após muitas tentativas -- ✅ Validação de campos obrigatórios -``` - -## 9. Configuração de Ambiente - -### 9.1. Variáveis de Ambiente - -```bash -# .env -NODE_ENV=development -PORT=3000 -PG_DB_URL=postgresql://user:password@localhost:5432/gamix_db -JWT_SIGNING_KEY_SECRET=your_secret_key -AWS_REGION=us-east-1 -AWS_ACCESS_KEY_ID=your_access_key -AWS_SECRET_ACCESS_KEY=your_secret_key -``` - -### 9.2. Docker Compose - -```yaml -# docker-compose.yaml -version: '3.8' - -services: - postgres: - image: postgres:15-alpine - environment: - POSTGRES_DB: gamix_db - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - ports: - - "5432:5432" - volumes: - - postgres_data:/var/lib/postgresql/data - -volumes: - postgres_data: -``` - -## 10. Deploy - -### 10.1. Vercel Configuration - -```json -// vercel.json -{ - "version": 2, - "builds": [ - { - "src": "dist/main.js", - "use": "@vercel/node" - } - ], - "routes": [ - { - "src": "/(.*)", - "dest": "dist/main.js" - } - ] -} -``` - -## 11. Boas Práticas - -### 11.1. Código -- ✅ Sempre usar TypeScript -- ✅ Implementar logging em todas as operações -- ✅ Validar dados de entrada -- ✅ Tratar erros adequadamente -- ✅ Escrever testes para todas as features -- ✅ Documentar cada feature com .feature.md - -### 11.2. Segurança -- ✅ Hash de senhas com bcrypt -- ✅ Validação de JWT tokens -- ✅ Rate limiting para endpoints sensíveis -- ✅ Sanitização de dados de entrada -- ✅ Headers de segurança - -### 11.3. Performance -- ✅ Paginação em listagens -- ✅ Índices no banco de dados -- ✅ Logging de tempo de resposta -- ✅ Otimização de queries - -## 12. Considerações Finais - -- **Simplicidade**: Manter a arquitetura simples e compreensível -- **Testes**: Escrever testes abrangentes para todas as funcionalidades -- **Documentação**: Manter documentação atualizada -- **Segurança**: Implementar práticas de segurança adequadas -- **Performance**: Monitorar e otimizar performance -- **Manutenibilidade**: Código limpo e bem estruturado - -Ao seguir estas diretrizes, os desenvolvedores podem contribuir de forma consistente e eficiente para o backend da plataforma Gamix. \ No newline at end of file diff --git a/env.example b/env.example index 533f2c7..9b8000f 100644 --- a/env.example +++ b/env.example @@ -15,3 +15,4 @@ GITHUB_CLIENT_SECRET= LINKEDIN_CLIENT_ID= LINKEDIN_CLIENT_SECRET= REDIRECT_URL= +FRONTEND_URL=http://localhost:3000 diff --git a/eslint.config.mjs b/eslint.config.mjs index b7131d9..105647d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -29,15 +29,24 @@ export default tseslint.config( '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-floating-promises': 'off', '@typescript-eslint/no-unsafe-call': 'off', - '@typescript-eslint/no-unsafe-argument': 'warn', '@typescript-eslint/no-unsafe-assignment': 'off', '@typescript-eslint/no-unsafe-member-access': 'off', '@typescript-eslint/no-unsafe-return': 'off', - '@typescript-eslint/no-unused-vars': ['error', { - 'argsIgnorePattern': '^_', - 'varsIgnorePattern': '^_', - 'caughtErrorsIgnorePattern': '^_' - }], + '@typescript-eslint/no-unsafe-argument': 'warn', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + }, + }, + { + files: ['**/*.json'], + rules: { + 'eol-last': 'off', }, }, -); \ No newline at end of file +); diff --git a/package-lock.json b/package-lock.json index c15d088..25625d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,10 @@ "@nestjs/jwt": "^11.0.0", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.1.6", + "@nestjs/platform-socket.io": "^11.1.7", + "@nestjs/throttler": "^6.4.0", "@nestjs/typeorm": "^11.0.0", + "@nestjs/websockets": "^11.1.7", "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", @@ -26,6 +29,7 @@ "pg": "^8.16.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "socket.io": "^4.8.1", "typeorm-naming-strategies": "^4.1.0" }, "devDependencies": { @@ -3958,6 +3962,25 @@ "@nestjs/core": "^11.0.0" } }, + "node_modules/@nestjs/platform-socket.io": { + "version": "11.1.7", + "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.7.tgz", + "integrity": "sha512-suAyy5JWWvqU0fXbRp79Ihy7a1HSfB5rKgecVRmuQQyTi28W/0lsRsJN41plsxOEiXtaZq7sqiQp5Dg4XeUc9g==", + "license": "MIT", + "dependencies": { + "socket.io": "4.8.1", + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/websockets": "^11.0.0", + "rxjs": "^7.1.0" + } + }, "node_modules/@nestjs/schematics": { "version": "11.0.7", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.7.tgz", @@ -4003,6 +4026,17 @@ } } }, + "node_modules/@nestjs/throttler": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.4.0.tgz", + "integrity": "sha512-osL67i0PUuwU5nqSuJjtUJZMkxAnYB4VldgYUMGzvYRJDCqGRFMWbsbzm/CkUtPLRL30I8T74Xgt/OQxnYokiA==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0" + } + }, "node_modules/@nestjs/typeorm": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-11.0.0.tgz", @@ -4016,6 +4050,29 @@ "typeorm": "^0.3.0" } }, + "node_modules/@nestjs/websockets": { + "version": "11.1.7", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.7.tgz", + "integrity": "sha512-FWPgZPN7yQWIeonQ7JL64Rbsbw/IQovft0cVC5UX1Jbsovq+rUaTuk3rilimGrawN9VOGcoiQLGNiIbmjjiCew==", + "license": "MIT", + "dependencies": { + "iterare": "1.2.1", + "object-hash": "3.0.0", + "tslib": "2.8.1" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/platform-socket.io": "^11.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/platform-socket.io": { + "optional": true + } + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -4874,6 +4931,12 @@ "node": ">=18.0.0" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@sqltools/formatter": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz", @@ -5017,6 +5080,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -6380,6 +6452,15 @@ ], "license": "MIT" }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.8.4", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.4.tgz", @@ -7366,6 +7447,95 @@ "node": ">= 0.8" } }, + "node_modules/engine.io": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz", + "integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", @@ -10422,6 +10592,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -11615,6 +11794,141 @@ "node": ">=8" } }, + "node_modules/socket.io": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", + "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.3.2", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz", + "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==", + "license": "MIT", + "dependencies": { + "debug": "~4.3.4", + "ws": "~8.17.1" + } + }, + "node_modules/socket.io-adapter/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/source-map": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", @@ -13427,6 +13741,27 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index c09651a..3f506f2 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,10 @@ "@nestjs/jwt": "^11.0.0", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.1.6", + "@nestjs/platform-socket.io": "^11.1.7", + "@nestjs/throttler": "^6.4.0", "@nestjs/typeorm": "^11.0.0", + "@nestjs/websockets": "^11.1.7", "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", @@ -35,6 +38,7 @@ "pg": "^8.16.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "socket.io": "^4.8.1", "typeorm-naming-strategies": "^4.1.0" }, "devDependencies": { diff --git a/src/app.module.ts b/src/app.module.ts index 91a40fb..d74f1b3 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,46 +1,64 @@ +import { S3Client } from '@aws-sdk/client-s3'; +import { SESv2Client } from '@aws-sdk/client-sesv2'; +import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; +import { APP_GUARD } from '@nestjs/core'; +import { JwtModule } from '@nestjs/jwt'; +import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { Module } from '@nestjs/common'; -import { GetHealthStatusController } from './features/get-health-status/v1/get-health-status.controller'; import { join } from 'path'; import { SnakeNamingStrategy } from 'typeorm-naming-strategies'; -import { User } from './entities/user.entity'; -import { UserCode } from './entities/user-code.entity'; -import { UserOAuth } from './entities/user-oauth.entity'; -import { UserProfile } from './entities/user-profile.entity'; -import { SignInController } from './features/auth/signin/v1/signin.controller'; -import { SignupController } from './features/auth/signup/v1/signup.controller'; -import { APP_GUARD } from '@nestjs/core'; -import { JwtAuthGuard } from './jwt/auth.guard'; -import { JwtStrategy } from './jwt/jwt.strategy'; +import { Chat } from './entities/chat.entity'; +import { CollaborationRequest } from './entities/collaboration-request.entity'; +import { Comment } from './entities/comment.entity'; import { Follow } from './entities/follow.entity'; -import { Link } from './entities/link.entity'; -import { JwtModule } from '@nestjs/jwt'; -import { SESGateway, SES_CLIENT } from './shared/gateways/ses.gateway'; -import { SESv2Client } from '@aws-sdk/client-sesv2'; -import { RefreshTokenController } from './features/auth/refresh-token/v1/refresh-token.controller'; -import { GetAllUsersController } from './features/user-management/get-all-users/v1/get-all-users.controller'; -import { GetUserByIdController } from './features/user-management/get-user-by-id/v1/get-user-by-id.controller'; -import { DeleteUserController } from './features/user-management/delete-user/v1/delete-user.controller'; -import { ToggleFollowController } from './features/user-management/toggle-follow/v1/toggle-follow.controller'; -import { S3Client } from '@aws-sdk/client-s3'; -import { S3_CLIENT, S3Gateway } from './shared/gateways/s3.gateway'; -import { UpdateUserController } from './features/user-management/update-user/v1/update-user.controller'; -import { CreateIdeaController } from './features/feed/ideas/create-idea/v1/create-idea.controller'; import { Idea } from './entities/idea.entity'; -import { Image } from './entities/image.entity'; import { Like } from './entities/like.entity'; -import { View } from './entities/view.entity'; +import { Notification } from './entities/notification.entity'; +import { OAuth } from './entities/oauth.entity'; +import { Profile } from './entities/profile.entity'; +import { Project } from './entities/project.entity'; import { Tag } from './entities/tag.entity'; -import { Comment } from './entities/comment.entity'; +import { User } from './entities/user.entity'; +import { View } from './entities/view.entity'; +import { RefreshTokenController } from './features/auth/refresh-token/v1/refresh-token.controller'; +import { SignInController } from './features/auth/signin/v1/signin.controller'; +import { SignupController } from './features/auth/signup/v1/signup.controller'; +import { GetAllChatsController } from './features/chat/get-all-chats/v1/get-all-chats.controller'; +import { GetChatController } from './features/chat/get-chat/v1/get-chat.controller'; +import { SendMessageController } from './features/chat/send-message/v1/send-message.controller'; +import { StartChatController } from './features/chat/start-chat/v1/start-chat.controller'; +import { CollaborationApprovalController } from './features/collaboration-requests/collaboration-approval/v1/collaboration-approval.controller'; +import { CollaborationRequestController } from './features/collaboration-requests/collaboration-request/v1/collaboration-request.controller'; +import { GetCollaborationRequestController } from './features/collaboration-requests/get-collaboration-request/v1/get-collaboration-request.controller'; +import { CreateCommentController } from './features/feed/comments/create-comment/v1/create-comment.controller'; +import { GetAllCommentsController } from './features/feed/comments/get-all-comments/v1/get-all-comments.controller'; +import { GetCommentByIdController } from './features/feed/comments/get-comment-by-id/v1/get-comment-by-id.controller'; +import { CreateIdeaController } from './features/feed/ideas/create-idea/v1/create-idea.controller'; +import { DeleteIdeaController } from './features/feed/ideas/delete-idea/v1/delete-idea.controller'; import { GetAllIdeasController } from './features/feed/ideas/get-all-ideas/v1/get-all-ideas.controller'; import { GetIdeaByIdController } from './features/feed/ideas/get-idea-by-id/v1/get-idea-by-id.controller'; +import { RecordViewController } from './features/feed/ideas/record-view/v1/record-view.controller'; import { UpdateIdeaController } from './features/feed/ideas/update-idea/v1/update-idea.controller'; -import { DeleteIdeaController } from './features/feed/ideas/delete-idea/v1/delete-idea.controller'; import { ToggleLikeController } from './features/feed/toggle-like/v1/toggle-like.controller'; -import { RecordViewController } from './features/feed/ideas/record-view/v1/record-view.controller'; -import { CreateCommentController } from './features/feed/comments/create-comment/v1/create-comment.controller'; -import { GetAllCommentsController } from './features/feed/comments/get-all-comments/v1/get-all-comments.controller'; +import { GetHealthStatusController } from './features/get-health-status/v1/get-health-status.controller'; +import { GetAllNotificationsController } from './features/notifications/get-all-notifications/v1/get-all-notifications.controller'; +import { ReadNotificationsController } from './features/notifications/read/v1/read.controller'; +import { DeleteProjectController } from './features/project/delete-project/v1/delete-project.controller'; +import { GetAllProjectsController } from './features/project/get-all-projects/v1/get-all-projects.controller'; +import { GetProjectController } from './features/project/get-project/v1/get-project.controller'; +import { RemoveMemberController } from './features/project/remove-member/v1/remove-member.controller'; +import { UpdateProjectController } from './features/project/update-project/v1/update-project.controller'; +import { DeleteUserController } from './features/user-management/delete-user/v1/delete-user.controller'; +import { GetAllUsersController } from './features/user-management/get-all-users/v1/get-all-users.controller'; +import { GetUserByIdController } from './features/user-management/get-user-by-id/v1/get-user-by-id.controller'; +import { ToggleFollowController } from './features/user-management/toggle-follow/v1/toggle-follow.controller'; +import { UpdateUserController } from './features/user-management/update-user/v1/update-user.controller'; +import { JwtAuthGuard } from './jwt/auth.guard'; +import { JwtStrategy } from './jwt/jwt.strategy'; +import { ChatGateway } from './shared/gateways/chat.gateway'; +import { S3Gateway, S3_CLIENT } from './shared/gateways/s3.gateway'; +import { SESGateway, SES_CLIENT } from './shared/gateways/ses.gateway'; @Module({ imports: [ @@ -63,22 +81,34 @@ import { GetAllCommentsController } from './features/feed/comments/get-all-comme }), TypeOrmModule.forFeature([ User, - UserCode, - UserOAuth, - UserProfile, + OAuth, + Profile, Follow, - Link, Idea, - Image, Like, View, Tag, Comment, + CollaborationRequest, + Chat, + Project, + Notification, ]), JwtModule.register({ secret: process.env.JWT_SIGNING_KEY_SECRET, signOptions: { expiresIn: '1h' }, }), + ThrottlerModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => [ + { + name: 'default', + ttl: config.get('RATE_LIMIT_TTL') ?? 60000, + limit: config.get('RATE_LIMIT') ?? 500, + }, + ], + }), ], providers: [ JwtStrategy, @@ -112,6 +142,8 @@ import { GetAllCommentsController } from './features/feed/comments/get-all-comme }, SESGateway, S3Gateway, + ChatGateway, + { provide: APP_GUARD, useClass: ThrottlerGuard }, ], controllers: [ GetHealthStatusController, @@ -132,6 +164,21 @@ import { GetAllCommentsController } from './features/feed/comments/get-all-comme RecordViewController, CreateCommentController, GetAllCommentsController, + GetCommentByIdController, + CollaborationRequestController, + GetCollaborationRequestController, + CollaborationApprovalController, + GetAllChatsController, + GetChatController, + StartChatController, + SendMessageController, + UpdateProjectController, + GetAllProjectsController, + GetProjectController, + DeleteProjectController, + RemoveMemberController, + GetAllNotificationsController, + ReadNotificationsController, ], }) export class AppModule {} diff --git a/src/entities/chat.entity.ts b/src/entities/chat.entity.ts new file mode 100644 index 0000000..4436b98 --- /dev/null +++ b/src/entities/chat.entity.ts @@ -0,0 +1,96 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + CreateDateColumn, + JoinColumn, + OneToOne, + ManyToMany, + JoinTable, + Index, +} from 'typeorm'; +import { Profile } from './profile.entity'; +import { Idea } from './idea.entity'; +import { CollaborationRequest } from './collaboration-request.entity'; +import { Project } from './project.entity'; + +@Entity('chats') +export class Chat { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'jsonb', default: [] }) + messages: { senderId: string; content: string; timestamp: Date }[]; + + @Column({ nullable: true }) + ownerId: string | null; + + @ManyToOne(() => Profile, { nullable: true, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'owner_id' }) + owner: Profile; + + @Column({ nullable: true }) + ideaId: string | null; + + @ManyToOne(() => Idea, { nullable: true, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'idea_id' }) + idea: Idea; + + @Column({ nullable: true }) + collaborationRequestId: string | null; + + @OneToOne(() => CollaborationRequest, { + nullable: true, + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'collaboration_request_id' }) + collaborationRequest: CollaborationRequest; + + @Column({ nullable: true }) + @Index() + projectId: string | null; + + @OneToOne(() => Project, (project) => project.chat, { + nullable: true, + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'project_id' }) + project: Project; + + @ManyToMany(() => Profile, { cascade: true }) + @JoinTable({ + name: 'chat_members', + joinColumn: { name: 'chat_id', referencedColumnName: 'id' }, + inverseJoinColumn: { name: 'profile_id', referencedColumnName: 'id' }, + }) + members: Profile[]; + + @CreateDateColumn() + createdAt: Date; + + toDto(currentUserId?: string, isListItem: boolean = false): ChatDto { + const messages = isListItem ? this.messages.slice(-1) : this.messages; + const otherMember = this.members?.find( + (member) => member.id !== currentUserId, + ); + const title = this.idea?.title ?? otherMember?.displayName ?? 'Chat'; + const image = this.idea?.images[0] ?? otherMember?.icon ?? null; + + return { + id: this.id, + title, + image, + messages, + createdAt: this.createdAt, + }; + } +} + +export class ChatDto { + id: string; + title: string; + image: string | null; + messages: { senderId: string; content: string; timestamp: Date }[]; + createdAt: Date; +} diff --git a/src/entities/collaboration-request.entity.ts b/src/entities/collaboration-request.entity.ts new file mode 100644 index 0000000..20ca2a2 --- /dev/null +++ b/src/entities/collaboration-request.entity.ts @@ -0,0 +1,87 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + CreateDateColumn, + JoinColumn, + OneToOne, +} from 'typeorm'; +import { Profile } from './profile.entity'; +import { Idea } from './idea.entity'; +import { Chat } from './chat.entity'; + +export enum CollaborationRequestStatus { + PENDING = 'Pending', + APPROVED = 'Approved', + REJECTED = 'Rejected', +} + +@Entity('collaboration_requests') +export class CollaborationRequest { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + ideaId: string; + + @Column() + requesterId: string; + + @Column({ type: 'varchar', nullable: true }) + chatId: string | null; + + @ManyToOne(() => Idea, (idea) => idea.collaborationRequests, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'idea_id' }) + idea: Idea; + + @ManyToOne(() => Profile, (profile) => profile.collaborationRequests, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'requester_id' }) + requester: Profile; + + @OneToOne(() => Chat, { + nullable: true, + }) + @JoinColumn({ name: 'chat_id' }) + chat: Chat; + + @Column({ + type: 'enum', + enum: CollaborationRequestStatus, + default: CollaborationRequestStatus.PENDING, + }) + status: CollaborationRequestStatus; + + @Column({ type: 'varchar', nullable: true }) + feedback: string | null; + + @Column() + message: string; + + @CreateDateColumn() + createdAt: Date; + + toDto(isRequester: boolean): CollaborationRequestDto { + const collaborationRequest = new CollaborationRequestDto(); + collaborationRequest.status = this.status; + collaborationRequest.feedback = this.feedback; + collaborationRequest.message = this.message; + collaborationRequest.ideaTitle = this.idea.title; + collaborationRequest.requesterName = !isRequester + ? this.requester.displayName + : null; + return collaborationRequest; + } +} + +export class CollaborationRequestDto { + feedback: string | null; + message: string; + status: CollaborationRequestStatus; + ideaTitle: string; + requesterName: string | null; +} diff --git a/src/entities/comment.entity.ts b/src/entities/comment.entity.ts index ac5df52..7056e12 100644 --- a/src/entities/comment.entity.ts +++ b/src/entities/comment.entity.ts @@ -5,10 +5,10 @@ import { ManyToOne, OneToMany, CreateDateColumn, - UpdateDateColumn, Index, + JoinColumn, } from 'typeorm'; -import { UserProfile, UserProfileDto } from './user-profile.entity'; +import { Profile, ProfileDto } from './profile.entity'; import { Idea } from './idea.entity'; import { Like } from './like.entity'; @@ -27,6 +27,7 @@ export class Comment { @ManyToOne(() => Idea, (idea) => idea.comments, { onDelete: 'CASCADE', }) + @JoinColumn({ name: 'idea_id' }) idea: Idea; @Column({ nullable: true }) @@ -36,6 +37,7 @@ export class Comment { @ManyToOne(() => Comment, (comment) => comment.replies, { onDelete: 'RESTRICT', }) + @JoinColumn({ name: 'parent_comment_id' }) parentComment: Comment | null; @OneToMany(() => Comment, (comment) => comment.parentComment) @@ -45,10 +47,11 @@ export class Comment { @Index() authorId: string; - @ManyToOne(() => UserProfile, (userProfile) => userProfile.comments, { + @ManyToOne(() => Profile, (profile) => profile.comments, { onDelete: 'CASCADE', }) - author: UserProfile; + @JoinColumn({ name: 'author_id' }) + author: Profile; @OneToMany(() => Like, (like) => like.comment) likes: Like[]; @@ -56,9 +59,6 @@ export class Comment { @CreateDateColumn() createdAt: Date; - @UpdateDateColumn() - updatedAt: Date; - toDto(currentUserId?: string): CommentDto { const dto = new CommentDto(); dto.id = this.id; @@ -69,10 +69,9 @@ export class Comment { dto.likes = this.likes?.length ?? 0; dto.replies = this.replies?.length ?? 0; dto.isLiked = currentUserId - ? this.likes?.some((like) => like.userProfileId === currentUserId) + ? this.likes?.some((like) => like.profileId === currentUserId) : false; dto.createdAt = this.createdAt; - dto.updatedAt = this.updatedAt; return dto; } } @@ -80,12 +79,11 @@ export class Comment { export class CommentDto { id: string; content: string; - author: UserProfileDto; + author: ProfileDto; ideaId: string; parentCommentId: string | null; likes: number; replies: number; isLiked: boolean; createdAt: Date; - updatedAt: Date; } diff --git a/src/entities/follow.entity.ts b/src/entities/follow.entity.ts index 8ccb58f..8ed5fa8 100644 --- a/src/entities/follow.entity.ts +++ b/src/entities/follow.entity.ts @@ -1,12 +1,14 @@ import { - Entity, - PrimaryGeneratedColumn, Column, - ManyToOne, CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, Unique, } from 'typeorm'; -import { UserProfile } from './user-profile.entity'; +import { Profile } from './profile.entity'; +import { Project } from './project.entity'; @Entity('follows') @Unique(['followerUserId', 'followingUserId']) @@ -17,18 +19,31 @@ export class Follow { @Column() followerUserId: string; - @Column() - followingUserId: string; + @Column({ nullable: true }) + followingUserId: string | null; + + @Column({ nullable: true }) + followingProjectId: string | null; + + @ManyToOne(() => Profile, (profile) => profile.following, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'follower_user_id' }) + followerUser: Profile; - @ManyToOne(() => UserProfile, (userProfile) => userProfile.following, { + @ManyToOne(() => Profile, (profile) => profile.followers, { onDelete: 'CASCADE', + nullable: true, }) - followerUser: UserProfile; + @JoinColumn({ name: 'following_user_id' }) + followingUser: Profile; - @ManyToOne(() => UserProfile, (userProfile) => userProfile.followers, { + @ManyToOne(() => Project, (project) => project.followers, { onDelete: 'CASCADE', + nullable: true, }) - followingUser: UserProfile; + @JoinColumn({ name: 'following_project_id' }) + followingProject: Project; @CreateDateColumn() createdAt: Date; diff --git a/src/entities/idea.entity.ts b/src/entities/idea.entity.ts index 4a61e4e..fa555e8 100644 --- a/src/entities/idea.entity.ts +++ b/src/entities/idea.entity.ts @@ -7,14 +7,22 @@ import { CreateDateColumn, UpdateDateColumn, Index, + JoinColumn, } from 'typeorm'; -import { UserProfile, UserProfileDto } from './user-profile.entity'; +import { Profile, ProfileDto } from './profile.entity'; import { Comment } from './comment.entity'; import { Like } from './like.entity'; import { View } from './view.entity'; import { Tag } from './tag.entity'; -import { Image, ImageDto } from './image.entity'; -import { Link } from './link.entity'; +import { CollaborationRequest } from './collaboration-request.entity'; +import { Chat } from './chat.entity'; +import { Project } from './project.entity'; + +export enum IdeaStatus { + CLOSED = 'Closed', + OPEN = 'Open', + CANCELED = 'Canceled', +} @Entity('ideas') export class Idea { @@ -31,36 +39,55 @@ export class Idea { @Index() authorId: string; - @ManyToOne(() => UserProfile, (userProfile) => userProfile.ideas, { + @Column({ type: 'enum', enum: IdeaStatus, default: IdeaStatus.OPEN }) + status: IdeaStatus; + + @ManyToOne(() => Profile, (profile) => profile.ideas, { onDelete: 'CASCADE', }) - author: UserProfile; + @JoinColumn({ name: 'author_id' }) + author: Profile; - @OneToMany(() => Comment, (comment) => comment.idea) + @Column({ nullable: true }) + @Index() + projectId: string | null; + + @ManyToOne(() => Project, (project) => project.posts, { + onDelete: 'CASCADE', + nullable: true, + }) + @JoinColumn({ name: 'project_id' }) + project: Project; + + @OneToMany(() => Comment, (comment) => comment.idea, { + cascade: true, + orphanedRowAction: 'delete', + }) comments: Comment[]; + @OneToMany(() => CollaborationRequest, (req) => req.idea) + collaborationRequests: CollaborationRequest[]; + @OneToMany(() => Like, (like) => like.idea) likes: Like[]; @OneToMany(() => View, (view) => view.idea) views: View[]; - @OneToMany(() => Link, (link) => link.idea, { - cascade: true, - orphanedRowAction: 'delete', - }) - links: Link[]; - @OneToMany(() => Tag, (tag) => tag.idea, { - cascade: true, - orphanedRowAction: 'delete', - }) + @Column({ type: 'jsonb', default: [] }) + links: string[]; + + @OneToMany(() => Tag, (tag) => tag.idea) tags: Tag[]; - @OneToMany(() => Image, (image) => image.idea, { + @Column({ type: 'jsonb', default: [] }) + images: string[]; + + @OneToMany(() => Chat, (chat) => chat.idea, { cascade: true, orphanedRowAction: 'delete', }) - images: Image[]; + chats: Chat[]; @CreateDateColumn() createdAt: Date; @@ -77,14 +104,14 @@ export class Idea { dto.comments = this.comments?.length ?? 0; dto.likes = this.likes?.length ?? 0; dto.views = this.views?.length ?? 0; - dto.links = this.links.map((link) => link.url) ?? []; + dto.links = this.links ?? []; dto.tags = this.tags?.map((tag) => tag.name) ?? []; - dto.images = this.images?.map((image) => image.toDto()) ?? []; + dto.images = this.images ?? []; dto.isLiked = currentUserId - ? this.likes?.some((like) => like.userProfileId === currentUserId) + ? this.likes?.some((like) => like.profileId === currentUserId) : false; dto.isViewed = currentUserId - ? this.views?.some((view) => view.userProfileId === currentUserId) + ? this.views?.some((view) => view.profileId === currentUserId) : false; dto.createdAt = this.createdAt; dto.updatedAt = this.updatedAt; @@ -96,13 +123,13 @@ export class IdeaDto { id: string; title: string; content: string; - author?: UserProfileDto; + author?: ProfileDto; comments: number; likes: number; views: number; links: string[]; tags: string[]; - images: ImageDto[]; + images: string[]; isLiked: boolean; isViewed: boolean; createdAt: Date; diff --git a/src/entities/image.entity.ts b/src/entities/image.entity.ts deleted file mode 100644 index ac718b6..0000000 --- a/src/entities/image.entity.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - ManyToOne, - CreateDateColumn, - UpdateDateColumn, - Index, -} from 'typeorm'; -import { Idea } from './idea.entity'; - -@Entity('images') -export class Image { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ type: 'varchar', length: 2048 }) - src: string; - - @Column({ type: 'varchar', length: 255 }) - alt: string; - - @Column({ nullable: true }) - @Index() - ideaId: string | null; - - @ManyToOne(() => Idea, (idea) => idea.images, { - onDelete: 'CASCADE', - }) - idea: Idea | null; - - @CreateDateColumn() - createdAt: Date; - - @UpdateDateColumn() - updatedAt: Date; - - toDto(): ImageDto { - const dto = new ImageDto(); - dto.id = this.id; - dto.src = this.src; - dto.alt = this.alt; - return dto; - } -} - -export class ImageDto { - id: string; - src: string; - alt: string; -} diff --git a/src/entities/like.entity.ts b/src/entities/like.entity.ts index 48f8a26..08b35d4 100644 --- a/src/entities/like.entity.ts +++ b/src/entities/like.entity.ts @@ -6,14 +6,15 @@ import { CreateDateColumn, Index, Unique, + JoinColumn, } from 'typeorm'; -import { UserProfile } from './user-profile.entity'; +import { Profile } from './profile.entity'; import { Idea } from './idea.entity'; import { Comment } from './comment.entity'; @Entity('likes') -@Unique(['userProfileId', 'ideaId']) -@Unique(['userProfileId', 'commentId']) +@Unique(['profileId', 'ideaId']) +@Unique(['profileId', 'commentId']) export class Like { @PrimaryGeneratedColumn('uuid') id: string; @@ -25,6 +26,7 @@ export class Like { @ManyToOne(() => Idea, (idea) => idea.likes, { onDelete: 'CASCADE', }) + @JoinColumn({ name: 'idea_id' }) idea: Idea | null; @Column({ nullable: true }) @@ -34,16 +36,18 @@ export class Like { @ManyToOne(() => Comment, (comment) => comment.likes, { onDelete: 'CASCADE', }) + @JoinColumn({ name: 'comment_id' }) comment: Comment | null; @Column() @Index() - userProfileId: string; + profileId: string; - @ManyToOne(() => UserProfile, (userProfile) => userProfile.likes, { + @ManyToOne(() => Profile, (profile) => profile.likes, { onDelete: 'CASCADE', }) - userProfile: UserProfile; + @JoinColumn({ name: 'profile_id' }) + profile: Profile; @CreateDateColumn() createdAt: Date; diff --git a/src/entities/link.entity.ts b/src/entities/link.entity.ts deleted file mode 100644 index 503e087..0000000 --- a/src/entities/link.entity.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - ManyToOne, - CreateDateColumn, - UpdateDateColumn, - Index, -} from 'typeorm'; -import { UserProfile } from './user-profile.entity'; -import { Idea } from './idea.entity'; - -@Entity('links') -export class Link { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ nullable: true }) - @Index() - ideaId: string | null; - - @Column({ nullable: true }) - @Index() - userProfileId: string | null; - - @Column({ length: 700 }) - url: string; - - @ManyToOne(() => UserProfile, (userProfile) => userProfile.links, { - onDelete: 'CASCADE', - }) - userProfile: UserProfile; - - @ManyToOne(() => Idea, (idea) => idea.links, { - onDelete: 'CASCADE', - }) - idea: Idea; - - @CreateDateColumn() - createdAt: Date; - - @UpdateDateColumn() - updatedAt: Date; -} diff --git a/src/entities/notification.entity.ts b/src/entities/notification.entity.ts new file mode 100644 index 0000000..784df3e --- /dev/null +++ b/src/entities/notification.entity.ts @@ -0,0 +1,119 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { Profile } from './profile.entity'; + +export enum NotificationType { + INVITE = 'Invite', + COMMENT = 'Comment', + LIKE = 'Like', + FOLLOW = 'Follow', +} + +export enum RelatedEntityType { + COLLABORATION_REQUEST = 'COLLABORATION_REQUEST', + IDEA = 'IDEA', + COMMENT = 'COMMENT', + PROJECT = 'PROJECT', +} + +@Entity('notifications') +export class Notification { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + @Index() + userProfileId: string; + + @Column({ + type: 'enum', + enum: NotificationType, + }) + type: NotificationType; + + @Column({ type: 'varchar', length: 100 }) + title: string; + + @Column({ type: 'varchar', length: 300 }) + message: string; + + @Column({ type: 'timestamp', nullable: true }) + readAt: Date | null; + + @Column({ nullable: true }) + actorProfileId: string | null; + + @Column({ type: 'varchar', nullable: true }) + relatedEntityId: string | null; + + @Column({ + type: 'enum', + enum: RelatedEntityType, + nullable: true, + }) + relatedEntityType: RelatedEntityType | null; + + @ManyToOne(() => Profile, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_profile_id' }) + userProfile: Profile; + + @ManyToOne(() => Profile, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'actor_profile_id' }) + actorProfile: Profile | null; + + @CreateDateColumn() + createdAt: Date; + + toDto(): NotificationDto { + const dto = new NotificationDto(); + dto.id = this.id; + dto.type = this.type; + dto.title = this.title; + dto.message = this.message; + dto.readAt = this.readAt; + dto.createdAt = this.createdAt; + dto.read = this.readAt !== null; + dto.actorProfileId = this.actorProfileId; + dto.actorProfile = this.actorProfile ? this.actorProfile.toDto() : null; + dto.relatedEntityId = this.relatedEntityId; + dto.relatedEntityType = this.relatedEntityType; + return dto; + } +} + +export class NotificationDto { + id: string; + type: NotificationType; + title: string; + message: string; + read: boolean; + readAt: Date | null; + createdAt: Date; + actorProfileId: string | null; + actorProfile: { + id: string; + displayName: string; + icon?: string | null; + autobiography?: string | null; + backgroundImage?: string | null; + links: string[]; + followers: number; + following: number; + isFollowing: boolean; + user: { + id: string; + email: string; + username: string; + verified: boolean; + } | null; + } | null; + relatedEntityId: string | null; + relatedEntityType: RelatedEntityType | null; +} diff --git a/src/entities/user-oauth.entity.ts b/src/entities/oauth.entity.ts similarity index 58% rename from src/entities/user-oauth.entity.ts rename to src/entities/oauth.entity.ts index 556c603..1f842ba 100644 --- a/src/entities/user-oauth.entity.ts +++ b/src/entities/oauth.entity.ts @@ -4,20 +4,27 @@ import { Column, ManyToOne, CreateDateColumn, + JoinColumn, Index, Unique, } from 'typeorm'; import { User } from './user.entity'; -@Entity('user_oauth') +export enum OAuthProvider { + GOOGLE = 'Google', + GITHUB = 'Github', + LINKEDIN = 'LinkedIn', +} + +@Entity('oauth') @Unique(['provider', 'email']) @Index(['userId']) -export class UserOAuth { +export class OAuth { @PrimaryGeneratedColumn('uuid') id: string; - @Column() - provider: string; + @Column({ type: 'enum', enum: OAuthProvider }) + provider: OAuthProvider; @Column({ unique: true }) email: string; @@ -25,9 +32,10 @@ export class UserOAuth { @Column() userId: string; - @ManyToOne(() => User, (user) => user.oauthAccounts, { + @ManyToOne(() => User, (user) => user.oauth, { onDelete: 'CASCADE', }) + @JoinColumn({ name: 'user_id' }) user: User; @CreateDateColumn() diff --git a/src/entities/user-profile.entity.ts b/src/entities/profile.entity.ts similarity index 54% rename from src/entities/user-profile.entity.ts rename to src/entities/profile.entity.ts index 561ba5e..2f747b6 100644 --- a/src/entities/user-profile.entity.ts +++ b/src/entities/profile.entity.ts @@ -6,22 +6,25 @@ import { CreateDateColumn, UpdateDateColumn, OneToMany, + ManyToMany, } from 'typeorm'; import { User, UserDto } from './user.entity'; import { Follow } from './follow.entity'; -import { Link } from './link.entity'; import { Idea } from './idea.entity'; import { Comment } from './comment.entity'; import { Like } from './like.entity'; import { View } from './view.entity'; +import { CollaborationRequest } from './collaboration-request.entity'; +import { Chat } from './chat.entity'; +import { Project } from './project.entity'; -@Entity('user_profiles') -export class UserProfile { +@Entity('profiles') +export class Profile { @PrimaryGeneratedColumn('uuid') id: string; - @Column({ type: 'varchar', length: 300, nullable: true }) - displayName: string | null; + @Column({ type: 'varchar', length: 300 }) + displayName: string; @Column({ type: 'varchar', length: 2048, nullable: true }) icon: string | null; @@ -32,50 +35,71 @@ export class UserProfile { @Column({ type: 'varchar', length: 2048, nullable: true }) backgroundImage: string | null; - @OneToOne(() => User, (user) => user.userProfile, { - cascade: true, - onDelete: 'CASCADE', - }) + @OneToOne(() => User, (user) => user.profile) user: User; - @OneToMany(() => Link, (link) => link.userProfile, { + @Column({ type: 'jsonb', default: [] }) + links: string[]; + + @OneToMany(() => Follow, (follow) => follow.followingUser, { cascade: true, orphanedRowAction: 'delete', }) - links: Link[]; - - @OneToMany(() => Follow, (follow) => follow.followingUser) followers: Follow[]; - @OneToMany(() => Follow, (follow) => follow.followerUser) + @OneToMany(() => Follow, (follow) => follow.followerUser, { + cascade: true, + orphanedRowAction: 'delete', + }) following: Follow[]; - @OneToMany(() => Idea, (idea) => idea.author) + @OneToMany(() => Idea, (idea) => idea.author, { + cascade: true, + orphanedRowAction: 'delete', + }) ideas: Idea[]; - @OneToMany(() => Comment, (comment) => comment.author) + @OneToMany(() => CollaborationRequest, (req) => req.requester) + collaborationRequests: CollaborationRequest[]; + + @OneToMany(() => Comment, (comment) => comment.author, { + cascade: true, + orphanedRowAction: 'delete', + }) comments: Comment[]; - @OneToMany(() => Like, (like) => like.userProfile) + @OneToMany(() => Like, (like) => like.profile, { + cascade: true, + orphanedRowAction: 'delete', + }) likes: Like[]; - @OneToMany(() => View, (view) => view.userProfile) + @OneToMany(() => View, (view) => view.profile, { + cascade: true, + orphanedRowAction: 'delete', + }) views: View[]; + @ManyToMany(() => Chat, (chat) => chat.members) + chats: Chat[]; + + @ManyToMany(() => Project, (project) => project.members) + projects: Project[]; + @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; - toDto(currentUserId?: string): UserProfileDto { - const dto = new UserProfileDto(); + toDto(currentUserId?: string): ProfileDto { + const dto = new ProfileDto(); dto.id = this.id; - dto.displayName = this.displayName ?? this.user.username; + dto.displayName = this.displayName; dto.autobiography = this.autobiography ?? null; dto.backgroundImage = this.backgroundImage ?? null; dto.icon = this.icon ?? null; - dto.links = this.links?.map((l) => l.url) || []; + dto.links = this.links ?? []; dto.followers = this.followers?.length ?? 0; dto.following = this.following?.length ?? 0; dto.isFollowing = currentUserId @@ -86,7 +110,7 @@ export class UserProfile { } } -export class UserProfileDto { +export class ProfileDto { id: string; displayName: string; autobiography?: string | null; diff --git a/src/entities/project.entity.ts b/src/entities/project.entity.ts new file mode 100644 index 0000000..377b3c6 --- /dev/null +++ b/src/entities/project.entity.ts @@ -0,0 +1,112 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + JoinTable, + ManyToMany, + ManyToOne, + OneToMany, + OneToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { Chat } from './chat.entity'; +import { Follow } from './follow.entity'; +import { Idea } from './idea.entity'; +import { Profile } from './profile.entity'; + +@Entity('project') +export class Project { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ length: 100, default: 'Default title' }) + title: string; + + @Column({ length: 300, default: 'Default description' }) + description: string; + + @Column({ type: 'varchar', length: 2048, nullable: true }) + icon: string | null; + + @Column({ type: 'varchar', length: 2048, nullable: true }) + backgroundImage: string | null; + + @Column() + @Index() + ownerId: string; + + @ManyToOne(() => Profile, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'owner_id' }) + owner: Profile; + + @CreateDateColumn() + createdAt: Date; + + @Column({ nullable: true }) + chatId: string | null; + + @OneToOne(() => Chat, { + nullable: true, + cascade: true, + }) + @JoinColumn({ name: 'chat_id' }) + chat: Chat; + + @ManyToMany(() => Profile, { cascade: true }) + @JoinTable({ + name: 'project_members', + joinColumn: { name: 'project_id', referencedColumnName: 'id' }, + inverseJoinColumn: { name: 'profile_id', referencedColumnName: 'id' }, + }) + members: Profile[]; + + @OneToMany(() => Idea, (idea) => idea.project, { + cascade: true, + orphanedRowAction: 'delete', + }) + posts: Idea[]; + + @OneToMany(() => Follow, (follow) => follow.followingProject, { + cascade: true, + orphanedRowAction: 'delete', + }) + followers: Follow[]; + + toDto(): ProjectDto { + const project = new ProjectDto(); + project.id = this.id; + project.title = this.title; + project.description = this.description; + project.icon = this.icon; + project.backgroundImage = this.backgroundImage; + project.ownerId = this.ownerId; + project.createdAt = this.createdAt; + project.followersCount = this.followers?.length ?? 0; + project.ideasCount = this.posts?.length ?? 0; + + project.topFollowers = + this.followers + ?.filter((follow) => follow.followerUser) + .map((follow) => ({ + name: follow.followerUser.displayName, + icon: follow.followerUser.icon, + })) ?? []; + + return project; + } +} + +export class ProjectDto { + id: string; + title: string; + description: string; + icon: string | null; + backgroundImage: string | null; + ownerId: string; + createdAt: Date; + followersCount: number; + ideasCount: number; + topFollowers: Array<{ name: string; icon: string | null }>; +} diff --git a/src/entities/tag.entity.ts b/src/entities/tag.entity.ts index 287692e..c8fe7a3 100644 --- a/src/entities/tag.entity.ts +++ b/src/entities/tag.entity.ts @@ -6,6 +6,7 @@ import { CreateDateColumn, UpdateDateColumn, Index, + JoinColumn, } from 'typeorm'; import { Idea } from './idea.entity'; @@ -14,7 +15,7 @@ export class Tag { @PrimaryGeneratedColumn('uuid') id: string; - @Column({ type: 'varchar', length: 50 }) + @Column({ type: 'varchar', length: 50, unique: true }) name: string; @Column() @@ -24,6 +25,7 @@ export class Tag { @ManyToOne(() => Idea, (idea) => idea.tags, { onDelete: 'CASCADE', }) + @JoinColumn({ name: 'idea_id' }) idea: Idea; @CreateDateColumn() diff --git a/src/entities/user-code.entity.ts b/src/entities/user-code.entity.ts deleted file mode 100644 index 2f8aca3..0000000 --- a/src/entities/user-code.entity.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - OneToOne, - CreateDateColumn, - UpdateDateColumn, -} from 'typeorm'; -import { User } from './user.entity'; - -@Entity('user_codes') -export class UserCode { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ type: 'varchar', length: 6, nullable: true }) - code: string | null; - - @OneToOne(() => User, (user) => user.userCode, { onDelete: 'CASCADE' }) - user: User; - - @CreateDateColumn() - createdAt: Date; - - @UpdateDateColumn() - updatedAt: Date; -} diff --git a/src/entities/user.entity.ts b/src/entities/user.entity.ts index 8680a05..abdeafd 100644 --- a/src/entities/user.entity.ts +++ b/src/entities/user.entity.ts @@ -8,9 +8,8 @@ import { CreateDateColumn, UpdateDateColumn, } from 'typeorm'; -import { UserCode } from './user-code.entity'; -import { UserProfile } from './user-profile.entity'; -import { UserOAuth } from './user-oauth.entity'; +import { Profile } from './profile.entity'; +import { OAuth } from './oauth.entity'; @Entity('users') export class User { @@ -29,31 +28,27 @@ export class User { @Column({ default: false }) verified: boolean; - @Column({ default: 0 }) - loginAttempts: number; - - @Column({ type: 'timestamp', nullable: true }) - blockedUntil: Date | null; - - @Column({ unique: true }) - userCodeId: string; - - @OneToOne(() => UserCode, (userCode) => userCode.user) - @JoinColumn({ name: 'user_code_id' }) - userCode: UserCode; + @Column({ length: 6, nullable: true }) + code: string; @Column({ unique: true }) - userProfileId: string; + profileId: string; - @OneToOne(() => UserProfile, (profile) => profile.user) - @JoinColumn({ name: 'user_profile_id' }) - userProfile: UserProfile; + @OneToOne(() => Profile, (profile) => profile.user, { + cascade: true, + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'profile_id' }) + profile: Profile; @Column({ type: 'varchar', unique: true, nullable: true }) refreshToken: string | null; - @OneToMany(() => UserOAuth, (oauth) => oauth.user) - oauthAccounts: UserOAuth[]; + @OneToMany(() => OAuth, (oauth) => oauth.user, { + cascade: true, + orphanedRowAction: 'delete', + }) + oauth: OAuth[]; @CreateDateColumn() createdAt: Date; diff --git a/src/entities/view.entity.ts b/src/entities/view.entity.ts index 2d144e7..1332ef3 100644 --- a/src/entities/view.entity.ts +++ b/src/entities/view.entity.ts @@ -6,12 +6,13 @@ import { CreateDateColumn, Index, Unique, + JoinColumn, } from 'typeorm'; -import { UserProfile } from './user-profile.entity'; +import { Profile } from './profile.entity'; import { Idea } from './idea.entity'; @Entity('views') -@Unique(['userProfileId', 'ideaId']) +@Unique(['profileId', 'ideaId']) export class View { @PrimaryGeneratedColumn('uuid') id: string; @@ -27,12 +28,13 @@ export class View { @Column() @Index() - userProfileId: string; + profileId: string; - @ManyToOne(() => UserProfile, (userProfile) => userProfile.views, { + @ManyToOne(() => Profile, (profile) => profile.views, { onDelete: 'CASCADE', }) - userProfile: UserProfile; + @JoinColumn({ name: 'profile_id' }) + profile: Profile; @CreateDateColumn() createdAt: Date; diff --git a/src/features/auth/refresh-token/v1/refresh-token.controller.ts b/src/features/auth/refresh-token/v1/refresh-token.controller.ts index 48de158..4261398 100644 --- a/src/features/auth/refresh-token/v1/refresh-token.controller.ts +++ b/src/features/auth/refresh-token/v1/refresh-token.controller.ts @@ -40,10 +40,10 @@ export class RefreshTokenController { const accessToken = this.jwtService.sign({ sub: user.id, - userProfileId: user.userProfileId, + userProfileId: user.profileId, }); const refreshToken = this.jwtService.sign( - { sub: user.id, userProfileId: user.userProfileId }, + { sub: user.id, userProfileId: user.profileId }, { expiresIn: REFRESH_TOKEN_EXPIRATION }, ); diff --git a/src/features/auth/refresh-token/v1/refresh-token.feature.md b/src/features/auth/refresh-token/v1/refresh-token.feature.md deleted file mode 100644 index ca878cf..0000000 --- a/src/features/auth/refresh-token/v1/refresh-token.feature.md +++ /dev/null @@ -1,56 +0,0 @@ -# Feature: Refresh Token - -## Descrição -Endpoint para renovação de tokens de acesso usando o refresh token. Permite que usuários mantenham sua sessão ativa sem precisar fazer login novamente. - -## Endpoint -`POST /v1/auth/refresh-token` - -## Request Body -```json -{ - "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." -} -``` - -## Response -```json -{ - "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", - "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." -} -``` - -## Regras de Negócio -- **Refresh token válido** deve ser fornecido -- **Usuário deve existir** no banco de dados -- **Novos tokens** são gerados (access e refresh) -- **Refresh token antigo** é substituído pelo novo -- **Expiração** de 1 hora para access token e 7 dias para refresh token - -## Validações -- **Refresh Token**: String obrigatória e válida -- **Usuário**: Deve existir no banco de dados -- **Token**: Deve ser um JWT válido e não expirado - -## Tratamento de Erros -- **400**: Refresh token inválido ou expirado -- **401**: Usuário não encontrado -- **422**: Validação de campos falhou - -## Segurança -- **JWT tokens** seguros com chave secreta -- **Rotação de tokens** para maior segurança -- **Validação** de assinatura do token -- **Verificação** de expiração - -## Dependências -- **User Entity**: Para busca e validação do usuário -- **JWT Service**: Para geração e validação de tokens -- **TypeORM**: Para operações de banco de dados - -## Fluxo de Autenticação -1. **Login inicial** → Retorna access + refresh tokens -2. **Access token expira** → Usar refresh token para renovar -3. **Refresh token expira** → Fazer login novamente -4. **Logout** → Invalidar refresh token (se implementado) diff --git a/src/features/auth/refresh-token/v1/refresh-token.spec.ts b/src/features/auth/refresh-token/v1/refresh-token.spec.ts index 3193ebe..88b0583 100644 --- a/src/features/auth/refresh-token/v1/refresh-token.spec.ts +++ b/src/features/auth/refresh-token/v1/refresh-token.spec.ts @@ -1,8 +1,8 @@ -import request from 'supertest'; -import { REFRESH_TOKEN_EXPIRATION } from 'src/jwt/constants'; -import { RefreshTokenOutput } from './refresh-token.dto'; import { randomUUID } from 'crypto'; +import { REFRESH_TOKEN_EXPIRATION } from 'src/jwt/constants'; +import request from 'supertest'; import { createTestUser } from 'test/test-helpers'; +import { RefreshTokenOutput } from './refresh-token.dto'; interface ErrorResponse { message: string; @@ -17,7 +17,7 @@ describe('/v1/auth/refresh-token (POST)', () => { ); const refreshToken = jwtService.sign( - { sub: user.id, userProfileId: user.userProfileId }, + { sub: user.id, userProfileId: user.profileId }, { expiresIn: REFRESH_TOKEN_EXPIRATION }, ); @@ -57,7 +57,7 @@ describe('/v1/auth/refresh-token (POST)', () => { // Create expired refresh token const expiredRefreshToken = jwtService.sign( - { sub: user.id, userProfileId: user.userProfileId }, + { sub: user.id, userProfileId: user.profileId }, { expiresIn: '-1h' }, // Expired 1 hour ago ); @@ -73,7 +73,7 @@ describe('/v1/auth/refresh-token (POST)', () => { it('should return 401 when user is not found', async () => { // Create refresh token for non-existent user const refreshToken = jwtService.sign( - { sub: randomUUID(), userProfileId: 'non-existent-profile-id' }, + { sub: randomUUID(), profileId: 'non-existent-profile-id' }, { expiresIn: REFRESH_TOKEN_EXPIRATION }, ); @@ -117,7 +117,7 @@ describe('/v1/auth/refresh-token (POST)', () => { ); const oldRefreshToken = jwtService.sign( - { sub: user.id, userProfileId: user.userProfileId }, + { sub: user.id, userProfileId: user.profileId }, { expiresIn: REFRESH_TOKEN_EXPIRATION }, ); @@ -151,7 +151,7 @@ describe('/v1/auth/refresh-token (POST)', () => { ); const refreshToken = jwtService.sign( - { sub: user.id, userProfileId: user.userProfileId }, + { sub: user.id, userProfileId: user.profileId }, { expiresIn: REFRESH_TOKEN_EXPIRATION }, ); diff --git a/src/features/auth/signin/v1/signin.controller.ts b/src/features/auth/signin/v1/signin.controller.ts index 1a92c3a..cf79d3c 100644 --- a/src/features/auth/signin/v1/signin.controller.ts +++ b/src/features/auth/signin/v1/signin.controller.ts @@ -14,94 +14,59 @@ import { SigninInput, SigninOutput } from './signin.dto'; import { compare } from 'bcrypt'; import { JwtService } from '@nestjs/jwt'; import { REFRESH_TOKEN_EXPIRATION } from 'src/jwt/constants'; -import { - InvalidEmailOrPassword, - TooManyLoginAttempts, -} from 'src/shared/errors'; -import { safeSave } from 'src/shared/utils/safeSave'; +import { InvalidEmailOrPassword } from 'src/shared/errors'; +import { safeSave } from 'src/shared/utils/safe-save.util'; +import { Throttle } from '@nestjs/throttler'; @Controller('/auth') export class SignInController { - private readonly MAX_ATTEMPTS = 5; - private readonly BLOCK_TIME_MS = 30 * 60 * 1000; readonly logger = new Logger(this.constructor.name); constructor( @InjectRepository(User) private readonly userRepository: Repository, private readonly jwtService: JwtService, - // private readonly sesGateway: SESGateway, ) {} @Post('/signin') @Version('1') @HttpCode(HttpStatus.OK) + @Throttle({ default: { limit: 5, ttl: 60000 } }) async signin(@Body() body: SigninInput): Promise { const user = await this.userRepository.findOne({ where: { email: body.email }, relations: [ - 'userProfile', - 'userProfile.links', - 'userProfile.followers', - 'userProfile.following', - 'userProfile.user', + 'profile', + 'profile.followers', + 'profile.following', + 'profile.user', ], }); if (!user) throw new InvalidEmailOrPassword(); - // if (!user.verified) { - // const res = await this.sesGateway.checkEmailStatus(body.email); - // if (res?.status === 'Success') { - // user.verified = true; - // await this.userRepository.save(user); - // await this.sesGateway.sendEmail({ - // template: 'SignUp', - // receiver: user.email, - // }); - // } - // } - - const now = new Date(); - - if (user.loginAttempts >= this.MAX_ATTEMPTS) { - if (user.blockedUntil && user.blockedUntil > now) { - throw new TooManyLoginAttempts(); - } - user.loginAttempts = 0; - user.blockedUntil = null; - await safeSave(this.userRepository, user); - } + // todo: if the user is not verified, send a verification email if (!(await compare(body.password, user.password))) { - user.loginAttempts += 1; - if (user.loginAttempts === this.MAX_ATTEMPTS) { - user.blockedUntil = new Date(now.getTime() + this.BLOCK_TIME_MS); - // await this.sesService.sendEmail({ template: 'BlockedAccount', receiver: email }); - } - await safeSave(this.userRepository, user); - throw new InvalidEmailOrPassword(); } const accessToken = this.jwtService.sign({ sub: user.id, - userProfileId: user.userProfileId, + userProfileId: user.profileId, }); const refreshToken = this.jwtService.sign( - { sub: user.id, userProfileId: user.userProfileId }, + { sub: user.id, userProfileId: user.profileId }, { expiresIn: REFRESH_TOKEN_EXPIRATION }, ); - user.loginAttempts = 0; - user.blockedUntil = null; user.refreshToken = refreshToken; await safeSave(this.userRepository, user); return { accessToken, refreshToken, - userProfile: user.userProfile.toDto(), + userProfile: user.profile.toDto(), }; } } diff --git a/src/features/auth/signin/v1/signin.dto.ts b/src/features/auth/signin/v1/signin.dto.ts index 13efe67..5b14104 100644 --- a/src/features/auth/signin/v1/signin.dto.ts +++ b/src/features/auth/signin/v1/signin.dto.ts @@ -1,5 +1,5 @@ import { IsNotEmpty, IsEmail, IsString } from 'class-validator'; -import { UserProfileDto } from 'src/entities/user-profile.entity'; +import { ProfileDto } from 'src/entities/profile.entity'; export class SigninInput { @IsEmail({}, { message: 'INVALID_EMAIL' }) @@ -14,5 +14,5 @@ export class SigninInput { export class SigninOutput { accessToken: string; refreshToken: string; - userProfile: UserProfileDto; + userProfile: ProfileDto; } diff --git a/src/features/auth/signin/v1/signin.feature.md b/src/features/auth/signin/v1/signin.feature.md deleted file mode 100644 index 510bd5e..0000000 --- a/src/features/auth/signin/v1/signin.feature.md +++ /dev/null @@ -1,67 +0,0 @@ -# Feature: Sign In - -## Descrição -Endpoint para autenticação de usuários na plataforma Gamix. Permite que usuários façam login com email e senha, retornando tokens de acesso e refresh. - -## Endpoint -`POST /v1/auth/signin` - -## Request Body -```json -{ - "email": "user@example.com", - "password": "password123" -} -``` - -## Response -```json -{ - "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", - "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", - "user": { - "id": "uuid", - "username": "username", - "email": "user@example.com", - "verified": true, - "userProfile": { - "id": "uuid", - "displayName": "Display Name", - "autobiography": "User bio", - "links": [], - "followers": [], - "following": [] - } - } -} -``` - -## Regras de Negócio -- **Máximo 5 tentativas de login** por usuário -- **Bloqueio temporário** de 30 minutos após 5 tentativas falhadas -- **Verificação de email** obrigatória (comentada no código) -- **Senha** deve ser validada com bcrypt -- **Tokens JWT** com expiração de 1 hora para access e 7 dias para refresh -- **Relacionamentos** carregados: userProfile, links, followers, following - -## Validações -- **Email**: Formato válido e obrigatório -- **Password**: String obrigatória -- **Usuário**: Deve existir no banco de dados -- **Senha**: Deve coincidir com o hash armazenado - -## Tratamento de Erros -- **400**: Senha ou e-mail incorretos -- **429**: Muitas tentativas de login (conta bloqueada temporariamente) - -## Segurança -- **Rate limiting** implícito através do contador de tentativas -- **Hash bcrypt** para comparação de senhas -- **JWT tokens** seguros com chave secreta -- **Bloqueio temporário** para prevenir ataques de força bruta - -## Dependências -- **User Entity**: Para busca e validação do usuário -- **JWT Service**: Para geração de tokens -- **BCrypt**: Para comparação de senhas -- **TypeORM**: Para operações de banco de dados diff --git a/src/features/auth/signin/v1/signin.spec.ts b/src/features/auth/signin/v1/signin.spec.ts index 635a553..e3c0812 100644 --- a/src/features/auth/signin/v1/signin.spec.ts +++ b/src/features/auth/signin/v1/signin.spec.ts @@ -1,6 +1,12 @@ import request from 'supertest'; import { SigninOutput } from './signin.dto'; import { createTestUser } from 'test/test-helpers'; +import { createTestModule } from 'test/module'; +import { SESGateway } from 'src/shared/gateways/ses.gateway'; +import { AppModule } from 'src/app.module'; +import { Test } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { mocksSetup } from 'test/test-setup'; interface ErrorResponse { message: string; @@ -53,29 +59,6 @@ describe('/v1/auth/signin (POST)', () => { expect(body.message).toEqual('INVALID_EMAIL_OR_PASSWORD'); }); - it('should return 429 when account is blocked due to too many attempts', async () => { - const user = await createTestUser( - 'testuser', - 'test@example.com', - 'correctpassword', - ); - const blockedUntil = new Date(Date.now() + 30 * 60 * 1000); // 30 minutes from now - user.loginAttempts = 5; - user.blockedUntil = blockedUntil; - await userRepository.save(user); - - const response = await request(app.getHttpServer()) - .post('/v1/auth/signin') - .send({ - email: 'test@example.com', - password: 'correctpassword', - }); - - expect(response.status).toEqual(429); - const body = response.body as ErrorResponse; - expect(body.message).toContain('TOO_MANY_LOGIN_ATTEMPTS'); - }); - it('should validate required fields', async () => { const response = await request(app.getHttpServer()) .post('/v1/auth/signin') @@ -84,7 +67,6 @@ describe('/v1/auth/signin (POST)', () => { expect(response.status).toEqual(400); const body = response.body as ErrorResponse; expect(body.message).toContain('EMAIL_REQUIRED'); - expect(body.message).toContain('PASSWORD_REQUIRED'); }); it('should validate email format', async () => { @@ -92,11 +74,59 @@ describe('/v1/auth/signin (POST)', () => { .post('/v1/auth/signin') .send({ email: 'invalid-email', - password: 'password123', + password: 'Password123!', }); expect(response.status).toEqual(400); const body = response.body as ErrorResponse; expect(body.message).toContain('INVALID_EMAIL'); }); + + describe('Throttling Tests', () => { + beforeEach(async () => { + await app.close(); + const moduleFixture = await Test.createTestingModule({ + imports: [AppModule], + }) + .overrideProvider(SESGateway) + .useValue({ + sendEmail: jest.fn().mockResolvedValue({ + $metadata: { httpStatusCode: 200 }, + MessageId: 'aaaaaaaa', + }), + }) + .overrideProvider(ConfigService) + .useValue({ + get: (key: string) => { + if (key === 'RATE_LIMIT') return 5; + if (key === 'RATE_LIMIT_TTL') return 950; + return process.env[key]; + }, + }) + .compile(); + + await mocksSetup(moduleFixture); + }); + + afterAll(async () => { + await app.close(); + const moduleFixture = await createTestModule(); + await mocksSetup(moduleFixture); + }); + + it('should return 429 when account is blocked due to too many attempts', async () => { + let response: request.Response | undefined; + for (let i = 0; i < 6; i++) { + response = await request(app.getHttpServer()) + .post('/v1/auth/signin') + .send({ + email: 'test@example.com', + password: 'correctpassword', + }); + } + expect(response!.status).toEqual(429); + const body = response!.body as ErrorResponse; + expect(body.message).toContain('TOO_MANY_REQUESTS'); + }); + }); }); diff --git a/src/features/auth/signup/v1/signup.controller.ts b/src/features/auth/signup/v1/signup.controller.ts index 2c7d6ea..0e12f01 100644 --- a/src/features/auth/signup/v1/signup.controller.ts +++ b/src/features/auth/signup/v1/signup.controller.ts @@ -14,12 +14,11 @@ import { InjectRepository } from '@nestjs/typeorm'; import { User } from 'src/entities/user.entity'; import { REFRESH_TOKEN_EXPIRATION } from 'src/jwt/constants'; import { JwtService } from '@nestjs/jwt'; -import { UserProfile } from 'src/entities/user-profile.entity'; -import { UserCode } from 'src/entities/user-code.entity'; +import { Profile } from 'src/entities/profile.entity'; import { hashSync } from 'bcrypt'; import { SESGateway } from 'src/shared/gateways/ses.gateway'; import { UserAlreadyExists } from 'src/shared/errors'; -import { safeSave } from 'src/shared/utils/safeSave'; +import { safeSave } from 'src/shared/utils/safe-save.util'; @Controller('/auth') export class SignupController { @@ -27,10 +26,8 @@ export class SignupController { constructor( @InjectRepository(User) private readonly userRepository: Repository, - @InjectRepository(UserProfile) - private readonly userProfileRepository: Repository, - @InjectRepository(UserCode) - private readonly userCodeRepository: Repository, + @InjectRepository(Profile) + private readonly profileRepository: Repository, private readonly sesGateway: SESGateway, private readonly jwtService: JwtService, ) {} @@ -44,33 +41,33 @@ export class SignupController { }); if (existingUser) throw new UserAlreadyExists(); - const userProfile = await safeSave(this.userProfileRepository, {}); - const userCode = await safeSave(this.userCodeRepository, {}); + const profile = await safeSave(this.profileRepository, { + displayName: body.username, + }); const password = hashSync(body.password, 10); const newUser = await safeSave(this.userRepository, { ...body, password, email: body.email.toLowerCase(), - userProfileId: userProfile.id, - userCodeId: userCode.id, + profileId: profile.id, }); - const userWithRelations = await this.userProfileRepository.findOne({ - where: { id: newUser.userProfileId }, - relations: ['links', 'followers', 'following', 'user'], + const userWithRelations = await this.profileRepository.findOne({ + where: { id: newUser.profileId }, + relations: ['followers', 'following', 'user'], }); const accessToken = this.jwtService.sign({ sub: newUser.id, - userProfileId: newUser.userProfileId, + userProfileId: newUser.profileId, }); const refreshToken = this.jwtService.sign( - { sub: newUser.id, userProfileId: newUser.userProfileId }, + { sub: newUser.id, userProfileId: newUser.profileId }, { expiresIn: REFRESH_TOKEN_EXPIRATION }, ); await this.userRepository.update(newUser.id, { refreshToken }); - this.sesGateway.verifyIdentity(body.email); + // todo: send email verification return { accessToken, diff --git a/src/features/auth/signup/v1/signup.dto.ts b/src/features/auth/signup/v1/signup.dto.ts index 6cb047f..5d2b15d 100644 --- a/src/features/auth/signup/v1/signup.dto.ts +++ b/src/features/auth/signup/v1/signup.dto.ts @@ -1,5 +1,5 @@ import { IsNotEmpty, IsEmail, IsString, Matches } from 'class-validator'; -import { UserProfileDto } from 'src/entities/user-profile.entity'; +import { ProfileDto } from 'src/entities/profile.entity'; export class SignupInput { @IsEmail({}, { message: 'INVALID_EMAIL' }) @@ -22,5 +22,5 @@ export class SignupInput { export class SignupOutput { accessToken: string; refreshToken: string; - userProfile: UserProfileDto; + userProfile: ProfileDto; } diff --git a/src/features/auth/signup/v1/signup.feature.md b/src/features/auth/signup/v1/signup.feature.md deleted file mode 100644 index 9e36134..0000000 --- a/src/features/auth/signup/v1/signup.feature.md +++ /dev/null @@ -1,85 +0,0 @@ -# Feature: Sign Up - -## Descrição -Endpoint para registro de novos usuários na plataforma Gamix. Permite que usuários criem uma conta com username, email e senha, retornando tokens de acesso e refresh. - -## Endpoint -`POST /v1/auth/signup` - -## Request Body -```json -{ - "email": "user@example.com", - "password": "Password123!", - "username": "username" -} -``` - -## Response -```json -{ - "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", - "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", - "user": { - "id": "uuid", - "username": "username", - "email": "user@example.com", - "verified": false, - "userProfile": { - "id": "uuid", - "displayName": "Display Name", - "autobiography": "User bio", - "links": [], - "followers": [], - "following": [] - } - } -} -``` - -## Regras de Negócio -- **Email único** no sistema -- **Username único** no sistema -- **Senha forte** com critérios específicos -- **Verificação de email** obrigatória (comentada no código) -- **Tokens JWT** com expiração de 1 hora para access e 7 dias para refresh -- **Relacionamentos** carregados: userProfile, links, followers, following - -## Validações - -### Email -- **Formato válido** e obrigatório -- **Único** no sistema - -### Password -- **Mínimo 6 caracteres** -- **Pelo menos 1 dígito** (0-9) -- **Pelo menos 1 letra maiúscula** (A-Z) -- **Pelo menos 1 caractere especial** ($*&@#! ) -- **Não pode conter** { ou } -- **Obrigatório** - -### Username -- **Mínimo 3 caracteres** -- **Máximo 255 caracteres** -- **Não pode conter** { ou } -- **Obrigatório** - -## Tratamento de Erros -- **400**: Dados inválidos ou usuário já existe -- **409**: Email ou username já cadastrado -- **422**: Validação de campos falhou - -## Segurança -- **Hash bcrypt** para senhas -- **JWT tokens** seguros com chave secreta -- **Validação rigorosa** de senhas -- **Prevenção** de caracteres perigosos - -## Dependências -- **User Entity**: Para criação do usuário -- **UserCode Entity**: Para código único do usuário -- **UserProfile Entity**: Para perfil do usuário -- **JWT Service**: Para geração de tokens -- **BCrypt**: Para hash de senhas -- **TypeORM**: Para operações de banco de dados diff --git a/src/features/auth/signup/v1/signup.spec.ts b/src/features/auth/signup/v1/signup.spec.ts index 52795c8..7e1786d 100644 --- a/src/features/auth/signup/v1/signup.spec.ts +++ b/src/features/auth/signup/v1/signup.spec.ts @@ -100,13 +100,14 @@ describe('/v1/auth/signup (POST)', () => { it('should return 400 when required fields are missing', async () => { const response = await request(app.getHttpServer()) .post('/v1/auth/signup') - .send({}); + .send({ + password: 'Password123!', + username: 'testuser', + }); expect(response.status).toEqual(400); const body = response.body as ErrorResponse; expect(body.message).toContain('EMAIL_REQUIRED'); - expect(body.message).toContain('PASSWORD_REQUIRED'); - expect(body.message).toContain('USERNAME_REQUIRED'); }); it('should return 409 when email already exists', async () => { diff --git a/src/features/chat/get-all-chats/v1/get-all-chats.controller.ts b/src/features/chat/get-all-chats/v1/get-all-chats.controller.ts new file mode 100644 index 0000000..97204a8 --- /dev/null +++ b/src/features/chat/get-all-chats/v1/get-all-chats.controller.ts @@ -0,0 +1,59 @@ +import { type RequestWithUserData } from 'src/jwt/jwt.strategy'; +import { + Controller, + Get, + HttpCode, + HttpStatus, + Logger, + Query, + Request, + Version, +} from '@nestjs/common'; +import { GetAllChatsOutput, GetAllChatsInput } from './get-all-chats.dto'; +import { Repository } from 'typeorm'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Chat } from 'src/entities/chat.entity'; +import { Protected } from 'src/shared/decorators/protected.decorator'; + +@Controller('/chat') +export class GetAllChatsController { + constructor( + @InjectRepository(Chat) + private readonly chatRepository: Repository, + ) {} + readonly logger = new Logger(this.constructor.name); + + @Get() + @Version('1') + @HttpCode(HttpStatus.OK) + @Protected() + async getAllChats( + @Request() req: RequestWithUserData, + @Query() { quantity, page }: GetAllChatsInput, + ): Promise { + const qb = this.chatRepository + .createQueryBuilder('chat') + .leftJoinAndSelect('chat.idea', 'idea') + .leftJoinAndSelect('chat.members', 'members') + .where('chat.ownerId = :currentUserId', { + currentUserId: req.user.userProfileId, + }) + .orWhere('members.id = :currentUserId', { + currentUserId: req.user.userProfileId, + }); + + qb.skip(page * quantity).take(quantity); + + const [chats, total] = await qb.getManyAndCount(); + const pages = Math.ceil(total / quantity); + const nextPage = page + 1; + + return { + total, + pages, + page, + nextPage: nextPage >= pages ? null : nextPage, + data: chats.map((chat) => chat.toDto(req.user?.userProfileId, true)), + }; + } +} diff --git a/src/features/chat/get-all-chats/v1/get-all-chats.dto.ts b/src/features/chat/get-all-chats/v1/get-all-chats.dto.ts new file mode 100644 index 0000000..bd676de --- /dev/null +++ b/src/features/chat/get-all-chats/v1/get-all-chats.dto.ts @@ -0,0 +1,25 @@ +import { Type } from 'class-transformer'; +import { IsNumber, IsOptional, Min } from 'class-validator'; +import { ChatDto } from 'src/entities/chat.entity'; + +export class GetAllChatsInput { + @IsOptional() + @IsNumber({}, { message: 'INVALID_PAGE' }) + @Min(0, { message: 'INVALID_PAGE' }) + @Type(() => Number) + page: number = 0; + + @Type(() => Number) + @IsNumber({}, { message: 'INVALID_QUANTITY' }) + @Min(1, { message: 'INVALID_QUANTITY' }) + @IsOptional() + quantity: number = 10; +} + +export class GetAllChatsOutput { + page: number; + nextPage: number | null; + pages: number; + total: number; + data: ChatDto[]; +} diff --git a/src/features/chat/get-chat/v1/get-chat.controller.ts b/src/features/chat/get-chat/v1/get-chat.controller.ts new file mode 100644 index 0000000..b654d7a --- /dev/null +++ b/src/features/chat/get-chat/v1/get-chat.controller.ts @@ -0,0 +1,43 @@ +import { type RequestWithUserData } from 'src/jwt/jwt.strategy'; +import { + Controller, + Get, + HttpCode, + HttpStatus, + Logger, + Param, + Request, + Version, +} from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Chat, ChatDto } from 'src/entities/chat.entity'; +import { Protected } from 'src/shared/decorators/protected.decorator'; +import { GetChatInput } from './get-chat.dto'; +import { ChatNotFound } from 'src/shared/errors'; + +@Controller('/chat') +export class GetChatController { + constructor( + @InjectRepository(Chat) + private readonly chatRepository: Repository, + ) {} + readonly logger = new Logger(this.constructor.name); + + @Get('/:chatId') + @Version('1') + @HttpCode(HttpStatus.OK) + @Protected() + async getChat( + @Request() req: RequestWithUserData, + @Param() { chatId }: GetChatInput, + ): Promise { + const chat = await this.chatRepository.findOne({ + where: { id: chatId }, + relations: ['idea', 'members'], + }); + + if (!chat) throw new ChatNotFound(); + return chat.toDto(req.user?.userProfileId); + } +} diff --git a/src/features/chat/get-chat/v1/get-chat.dto.ts b/src/features/chat/get-chat/v1/get-chat.dto.ts new file mode 100644 index 0000000..6b0582a --- /dev/null +++ b/src/features/chat/get-chat/v1/get-chat.dto.ts @@ -0,0 +1,6 @@ +import { IsUUID } from 'class-validator'; + +export class GetChatInput { + @IsUUID(undefined, { message: 'INVALID_CHAT_ID' }) + chatId: string; +} diff --git a/src/features/chat/send-message/v1/send-message.controller.ts b/src/features/chat/send-message/v1/send-message.controller.ts new file mode 100644 index 0000000..ea89cf1 --- /dev/null +++ b/src/features/chat/send-message/v1/send-message.controller.ts @@ -0,0 +1,71 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + Logger, + Post, + Request, + Version, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { SendMessageInput, SendMessageOutput } from './send-message.dto'; +import { Chat } from 'src/entities/chat.entity'; +import type { RequestWithUserData } from 'src/jwt/jwt.strategy'; +import { Protected } from 'src/shared/decorators/protected.decorator'; +import { safeSave } from 'src/shared/utils/safe-save.util'; +import { ChatNotFound, UserNotAuthorized } from 'src/shared/errors'; +import { ChatEvents, ChatGateway } from 'src/shared/gateways/chat.gateway'; + +@Controller('/chat') +export class SendMessageController { + constructor( + @InjectRepository(Chat) + private readonly chatRepository: Repository, + private readonly chatGateway: ChatGateway, + ) {} + readonly logger = new Logger(this.constructor.name); + + @Post('/send-message') + @Version('1') + @Protected() + @HttpCode(HttpStatus.CREATED) + async sendMessage( + @Request() { user: { userProfileId } }: RequestWithUserData, + @Body() { chatId, content }: SendMessageInput, + ): Promise { + const chat = await this.chatRepository.findOne({ + where: { id: chatId }, + relations: ['members'], + }); + + if (!chat) throw new ChatNotFound(); + + const isMember = chat.members?.some( + (member) => member.id === userProfileId, + ); + + if (!isMember) throw new UserNotAuthorized(); + + const newMessage = { + senderId: userProfileId, + content, + timestamp: new Date(), + }; + + const updatedMessages = [...(chat.messages || []), newMessage]; + + await safeSave(this.chatRepository, { + ...chat, + messages: updatedMessages, + }); + + this.chatGateway.broadcastToChat(chatId, ChatEvents.NEW_MESSAGE, { + chatId, + message: newMessage, + }); + + return newMessage; + } +} diff --git a/src/features/chat/send-message/v1/send-message.dto.ts b/src/features/chat/send-message/v1/send-message.dto.ts new file mode 100644 index 0000000..d3d10ea --- /dev/null +++ b/src/features/chat/send-message/v1/send-message.dto.ts @@ -0,0 +1,16 @@ +import { IsString, IsUUID, Length } from 'class-validator'; + +export class SendMessageInput { + @IsUUID(undefined, { message: 'INVALID_CHAT_ID' }) + chatId: string; + + @IsString({ message: 'INVALID_MESSAGE' }) + @Length(1, 255, { message: 'MESSAGE_TOO_LONG' }) + content: string; +} + +export class SendMessageOutput { + senderId: string; + content: string; + timestamp: Date; +} diff --git a/src/features/chat/start-chat/v1/start-chat.controller.ts b/src/features/chat/start-chat/v1/start-chat.controller.ts new file mode 100644 index 0000000..f05eafb --- /dev/null +++ b/src/features/chat/start-chat/v1/start-chat.controller.ts @@ -0,0 +1,81 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + Logger, + Post, + Request, + Version, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { StartChatInput } from './start-chat.dto'; +import { Chat, ChatDto } from 'src/entities/chat.entity'; +import { + CollaborationRequest, + CollaborationRequestStatus, +} from 'src/entities/collaboration-request.entity'; +import { Idea } from 'src/entities/idea.entity'; +import type { RequestWithUserData } from 'src/jwt/jwt.strategy'; +import { Protected } from 'src/shared/decorators/protected.decorator'; +import { safeSave } from 'src/shared/utils/safe-save.util'; +import { + IdeaNotFound, + PendingCollaborationRequestNotFound, + YouCannotStartChatForAnotherUserIdea, +} from 'src/shared/errors'; + +@Controller('/chat') +export class StartChatController { + constructor( + @InjectRepository(Chat) + private readonly chatRepository: Repository, + @InjectRepository(CollaborationRequest) + private readonly collaborationRequestRepository: Repository, + @InjectRepository(Idea) + private readonly ideaRepository: Repository, + ) {} + readonly logger = new Logger(this.constructor.name); + + @Post('/start') + @Version('1') + @Protected() + @HttpCode(HttpStatus.CREATED) + async startChat( + @Request() { user: { userProfileId } }: RequestWithUserData, + @Body() { ideaId, requesterId }: StartChatInput, + ): Promise { + const existingRequest = await this.collaborationRequestRepository.findOne({ + where: { + requesterId, + ideaId, + status: CollaborationRequestStatus.PENDING, + }, + }); + + if (!existingRequest) throw new PendingCollaborationRequestNotFound(); + + const idea = await this.ideaRepository.findOne({ + where: { id: ideaId }, + relations: ['author'], + }); + + if (!idea) throw new IdeaNotFound(); + + if (idea.authorId !== userProfileId) { + throw new YouCannotStartChatForAnotherUserIdea(); + } + + const chat = await safeSave(this.chatRepository, { + ownerId: userProfileId, + ideaId: idea.id, + collaborationRequestId: existingRequest.id, + members: [{ id: userProfileId }, { id: requesterId }], + }); + existingRequest.chatId = chat.id; + await safeSave(this.collaborationRequestRepository, existingRequest); + + return chat.toDto(userProfileId); + } +} diff --git a/src/features/chat/start-chat/v1/start-chat.dto.ts b/src/features/chat/start-chat/v1/start-chat.dto.ts new file mode 100644 index 0000000..c473a54 --- /dev/null +++ b/src/features/chat/start-chat/v1/start-chat.dto.ts @@ -0,0 +1,9 @@ +import { IsUUID } from 'class-validator'; + +export class StartChatInput { + @IsUUID(undefined, { message: 'INVALID_IDEA_ID' }) + ideaId: string; + + @IsUUID(undefined, { message: 'INVALID_REQUESTER_ID' }) + requesterId: string; +} diff --git a/src/features/collaboration-requests/collaboration-approval/v1/collaboration-approval.controller.ts b/src/features/collaboration-requests/collaboration-approval/v1/collaboration-approval.controller.ts new file mode 100644 index 0000000..3adef3d --- /dev/null +++ b/src/features/collaboration-requests/collaboration-approval/v1/collaboration-approval.controller.ts @@ -0,0 +1,155 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + Logger, + Patch, + Query, + Request, + Version, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Chat } from 'src/entities/chat.entity'; +import { + CollaborationRequest, + CollaborationRequestStatus, +} from 'src/entities/collaboration-request.entity'; +import { + Notification, + NotificationType, + RelatedEntityType, +} from 'src/entities/notification.entity'; +import { Project } from 'src/entities/project.entity'; +import type { RequestWithUserData } from 'src/jwt/jwt.strategy'; +import { Protected } from 'src/shared/decorators/protected.decorator'; +import { + ChatNotFound, + CollaborationRequestIsNotPending, + CollaborationRequestNotFound, + UserNotAuthorized, +} from 'src/shared/errors'; +import { safeSave } from 'src/shared/utils/safe-save.util'; +import { Repository } from 'typeorm'; +import { + CollaborationApprovalInput, + CollaborationApprovalQueryInput, +} from './collaboration-approval.dto'; + +@Controller('/collaboration-approval') +export class CollaborationApprovalController { + constructor( + @InjectRepository(CollaborationRequest) + private readonly collaborationRequestRepository: Repository, + @InjectRepository(Chat) + private readonly chatRepository: Repository, + @InjectRepository(Project) + private readonly projectRepository: Repository, + @InjectRepository(Notification) + private readonly notificationRepository: Repository, + ) {} + readonly logger = new Logger(this.constructor.name); + + @Patch() + @Version('1') + @Protected() + @HttpCode(HttpStatus.ACCEPTED) + async getCollaborationRequest( + @Request() { user: { userProfileId } }: RequestWithUserData, + @Query() + { collaborationRequestId: id, status }: CollaborationApprovalQueryInput, + @Body() { feedback }: CollaborationApprovalInput, + ): Promise { + const collaborationRequest = + await this.collaborationRequestRepository.findOne({ + where: { id }, + relations: ['idea', 'idea.project', 'requester'], + }); + if (!collaborationRequest) throw new CollaborationRequestNotFound(); + + if (collaborationRequest.status !== CollaborationRequestStatus.PENDING) { + throw new CollaborationRequestIsNotPending(); + } + + if (collaborationRequest.idea.authorId !== userProfileId) { + throw new UserNotAuthorized(); + } + + if ( + status === CollaborationRequestStatus.APPROVED && + !collaborationRequest.chatId + ) { + throw new ChatNotFound(); + } + + if (collaborationRequest.chatId) { + const chatIdToDelete = collaborationRequest.chatId; + collaborationRequest.chatId = null; + await safeSave(this.collaborationRequestRepository, collaborationRequest); + + await this.chatRepository.delete(chatIdToDelete); + } + + collaborationRequest.status = status; + collaborationRequest.feedback = feedback; + await safeSave(this.collaborationRequestRepository, collaborationRequest); + + const isApproved = status === CollaborationRequestStatus.APPROVED; + const titleCode = isApproved + ? 'REQUEST_COLLABORATION_APPROVED' + : 'REQUEST_COLLABORATION_REJECTED'; + const messageCode = isApproved + ? 'MESSAGE_REQUEST_COLLABORATION_APPROVED' + : 'MESSAGE_REQUEST_COLLABORATION_REJECTED'; + + await safeSave(this.notificationRepository, { + userProfileId: collaborationRequest.requesterId, + type: NotificationType.INVITE, + title: titleCode, + message: messageCode, + readAt: null, + actorProfileId: userProfileId, + relatedEntityId: collaborationRequest.id, + relatedEntityType: RelatedEntityType.COLLABORATION_REQUEST, + }); + + if (!isApproved) return; + + let project = collaborationRequest.idea.project; + if (!project) { + project = await safeSave(this.projectRepository, { + ownerId: userProfileId, + members: [ + { id: userProfileId }, + { id: collaborationRequest.requesterId }, + ], + }); + } else { + await this.projectRepository + .createQueryBuilder() + .relation(Project, 'members') + .of(project) + .add(collaborationRequest.requesterId); + } + + let chat = await this.chatRepository.findOne({ + where: { id: project.chatId! }, + relations: ['members'], + }); + if (!chat) { + chat = await safeSave(this.chatRepository, { + projectId: project.id, + ownerId: userProfileId, + members: [ + { id: userProfileId }, + { id: collaborationRequest.requesterId }, + ], + }); + project.chatId = chat.id; + await safeSave(this.projectRepository, project); + } else { + chat.members = [...chat.members, collaborationRequest.requester]; + await safeSave(this.chatRepository, chat); + } + } +} diff --git a/src/features/collaboration-requests/collaboration-approval/v1/collaboration-approval.dto.ts b/src/features/collaboration-requests/collaboration-approval/v1/collaboration-approval.dto.ts new file mode 100644 index 0000000..5400e4e --- /dev/null +++ b/src/features/collaboration-requests/collaboration-approval/v1/collaboration-approval.dto.ts @@ -0,0 +1,18 @@ +import { IsIn, IsString, IsUUID, Length } from 'class-validator'; +import { CollaborationRequestStatus } from 'src/entities/collaboration-request.entity'; + +export class CollaborationApprovalQueryInput { + @IsUUID(undefined, { message: 'INVALID_COLLABORATION_REQUEST_ID' }) + collaborationRequestId: string; + + @IsIn( + [CollaborationRequestStatus.APPROVED, CollaborationRequestStatus.REJECTED], + { message: 'INVALID_STATUS' }, + ) + readonly status: CollaborationRequestStatus; +} +export class CollaborationApprovalInput { + @IsString({ message: 'INVALID_FEEDBACK' }) + @Length(3, 255, { message: 'INVALID_FEEDBACK' }) + readonly feedback: string; +} diff --git a/src/features/collaboration-requests/collaboration-request/v1/collaboration-request.controller.ts b/src/features/collaboration-requests/collaboration-request/v1/collaboration-request.controller.ts new file mode 100644 index 0000000..29e1f7d --- /dev/null +++ b/src/features/collaboration-requests/collaboration-request/v1/collaboration-request.controller.ts @@ -0,0 +1,95 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + Logger, + Post, + Request, + Version, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { + CollaborationRequest, + CollaborationRequestStatus, +} from 'src/entities/collaboration-request.entity'; +import { Idea } from 'src/entities/idea.entity'; +import { + Notification, + NotificationType, + RelatedEntityType, +} from 'src/entities/notification.entity'; +import { Profile } from 'src/entities/profile.entity'; +import type { RequestWithUserData } from 'src/jwt/jwt.strategy'; +import { Protected } from 'src/shared/decorators/protected.decorator'; +import { + IdeaNotFound, + PendingCollaborationRequestAlreadyExists, + YouCannotRequestCollaborationForYourOwnIdea, +} from 'src/shared/errors'; +import { safeSave } from 'src/shared/utils/safe-save.util'; +import { Repository } from 'typeorm'; +import { CollaborationRequestInput } from './collaboration-request.dto'; + +@Controller('/collaboration-request') +export class CollaborationRequestController { + constructor( + @InjectRepository(CollaborationRequest) + private readonly collaborationRequestRepository: Repository, + @InjectRepository(Idea) + private readonly ideaRepository: Repository, + @InjectRepository(Notification) + private readonly notificationRepository: Repository, + @InjectRepository(Profile) + private readonly profileRepository: Repository, + ) {} + readonly logger = new Logger(this.constructor.name); + + @Post() + @Version('1') + @Protected() + @HttpCode(HttpStatus.CREATED) + async collaborationRequest( + @Request() { user: { userProfileId } }: RequestWithUserData, + @Body() { message, ideaId }: CollaborationRequestInput, + ): Promise { + const collaborationRequest = + await this.collaborationRequestRepository.findOne({ + where: { + requesterId: userProfileId, + ideaId, + status: CollaborationRequestStatus.PENDING, + }, + }); + if (collaborationRequest) { + throw new PendingCollaborationRequestAlreadyExists(); + } + const idea = await this.ideaRepository.findOne({ + where: { id: ideaId }, + }); + if (!idea) throw new IdeaNotFound(); + + if (idea.authorId === userProfileId) { + throw new YouCannotRequestCollaborationForYourOwnIdea(); + } + + const savedRequest = await safeSave(this.collaborationRequestRepository, { + ideaId, + message, + requesterId: userProfileId, + }); + + await safeSave(this.notificationRepository, { + userProfileId: idea.authorId, + type: NotificationType.INVITE, + title: 'NEW_COLLABORATION_REQUEST', + message: 'MESSAGE_NEW_COLLABORATION_REQUEST', + readAt: null, + actorProfileId: userProfileId, + relatedEntityId: savedRequest.id, + relatedEntityType: RelatedEntityType.COLLABORATION_REQUEST, + }); + + return savedRequest; + } +} diff --git a/src/features/collaboration-requests/collaboration-request/v1/collaboration-request.dto.ts b/src/features/collaboration-requests/collaboration-request/v1/collaboration-request.dto.ts new file mode 100644 index 0000000..8cd40ec --- /dev/null +++ b/src/features/collaboration-requests/collaboration-request/v1/collaboration-request.dto.ts @@ -0,0 +1,10 @@ +import { IsString, IsUUID, Matches } from 'class-validator'; + +export class CollaborationRequestInput { + @IsString({ message: 'INVALID_CONTENT' }) + @Matches(/^[^{}]{3,255}$/, { message: 'INVALID_CONTENT' }) + message: string; + + @IsUUID(undefined, { message: 'INVALID_IDEA_ID' }) + ideaId: string; +} diff --git a/src/features/collaboration-requests/get-collaboration-request/v1/get-collaboration-request.controller.ts b/src/features/collaboration-requests/get-collaboration-request/v1/get-collaboration-request.controller.ts new file mode 100644 index 0000000..3c263a0 --- /dev/null +++ b/src/features/collaboration-requests/get-collaboration-request/v1/get-collaboration-request.controller.ts @@ -0,0 +1,54 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Logger, + Query, + Request, + Version, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { + CollaborationRequest, + CollaborationRequestDto, +} from 'src/entities/collaboration-request.entity'; +import type { RequestWithUserData } from 'src/jwt/jwt.strategy'; +import { Protected } from 'src/shared/decorators/protected.decorator'; +import { Repository } from 'typeorm'; +import { GetCollaborationRequestInput } from './get-collaboration-request.dto'; +import { + CollaborationRequestNotFound, + UserNotAuthorized, +} from 'src/shared/errors'; + +@Controller('/get-collaboration-request') +export class GetCollaborationRequestController { + constructor( + @InjectRepository(CollaborationRequest) + private readonly collaborationRequestRepository: Repository, + ) {} + readonly logger = new Logger(this.constructor.name); + + @Get() + @Version('1') + @Protected() + @HttpCode(HttpStatus.OK) + async getCollaborationRequest( + @Request() { user: { userProfileId } }: RequestWithUserData, + @Query() { collaborationRequestId }: GetCollaborationRequestInput, + ): Promise { + const collaborationRequest = + await this.collaborationRequestRepository.findOne({ + where: { id: collaborationRequestId }, + relations: ['idea', 'requester'], + }); + if (!collaborationRequest) throw new CollaborationRequestNotFound(); + const isRequester = collaborationRequest.requesterId === userProfileId; + if (!isRequester && collaborationRequest.idea.authorId !== userProfileId) { + throw new UserNotAuthorized(); + } + return collaborationRequest.toDto(isRequester); + } +} diff --git a/src/features/collaboration-requests/get-collaboration-request/v1/get-collaboration-request.dto.ts b/src/features/collaboration-requests/get-collaboration-request/v1/get-collaboration-request.dto.ts new file mode 100644 index 0000000..df80613 --- /dev/null +++ b/src/features/collaboration-requests/get-collaboration-request/v1/get-collaboration-request.dto.ts @@ -0,0 +1,14 @@ +import { IsUUID } from 'class-validator'; +import { CollaborationRequest } from 'src/entities/collaboration-request.entity'; + +export class GetCollaborationRequestInput { + @IsUUID(undefined, { message: 'INVALID_COLLABORATION_REQUEST_ID' }) + collaborationRequestId: string; +} +export class GetCollaborationRequestOutput { + page: number; + nextPage: number | null; + pages: number; + total: number; + data: CollaborationRequest[]; +} diff --git a/src/features/feed/comments/create-comment/v1/create-comment.controller.ts b/src/features/feed/comments/create-comment/v1/create-comment.controller.ts index 9e732e6..48a9c27 100644 --- a/src/features/feed/comments/create-comment/v1/create-comment.controller.ts +++ b/src/features/feed/comments/create-comment/v1/create-comment.controller.ts @@ -14,7 +14,7 @@ import { Repository } from 'typeorm'; import { InjectRepository } from '@nestjs/typeorm'; import { Comment, CommentDto } from 'src/entities/comment.entity'; import { Protected } from 'src/shared/decorators/protected.decorator'; -import { safeSave } from 'src/shared/utils/safeSave'; +import { safeSave } from 'src/shared/utils/safe-save.util'; @Controller('/comment') export class CreateCommentController { diff --git a/src/features/feed/comments/create-comment/v1/create-comment.feature.md b/src/features/feed/comments/create-comment/v1/create-comment.feature.md deleted file mode 100644 index 768f827..0000000 --- a/src/features/feed/comments/create-comment/v1/create-comment.feature.md +++ /dev/null @@ -1,50 +0,0 @@ -# Feature: Create Comment - -## Descrição -Cria um comentário em uma ideia existente. - -## Endpoint -`POST /v1/feed/ideas/{ideaId}/comments` - -## Path Parameters -- **ideaId**: UUID da ideia - -## Request Body -```json -{ - "content": "Great idea!" -} -``` - -## Response -```json -{ - "id": "uuid", - "content": "Great idea!", - "author": { "id": "uuid", "username": "user" }, - "ideaId": "uuid", - "createdAt": "2025-01-01T00:00:00.000Z" -} -``` - -## Regras de Negócio -- **Autenticação obrigatória** -- Comentário vinculado a uma **ideia existente** - -## Validações -- **ideaId**: UUID válido -- **content**: string, 1-1000 caracteres, obrigatório - -## Tratamento de Erros -- **400**: Payload inválido -- **401**: Não autenticado -- **404**: Ideia não encontrada - -## Segurança -- **JWT** obrigatório - -## Dependências -- **Comment Entity**, **Idea Entity**, **User Entity** -- **TypeORM**, **Protected Decorator**, **JWT Strategy** - - diff --git a/src/features/feed/comments/create-comment/v1/create-comment.spec.ts b/src/features/feed/comments/create-comment/v1/create-comment.spec.ts index 26d7f16..9722a35 100644 --- a/src/features/feed/comments/create-comment/v1/create-comment.spec.ts +++ b/src/features/feed/comments/create-comment/v1/create-comment.spec.ts @@ -14,11 +14,11 @@ describe('/v1/comment (POST)', () => { const token = generateTestJwt({ sub: user.id, - userProfileId: user.userProfileId, + userProfileId: user.profileId, }); const idea = await ideaRepository.save({ - authorId: user.userProfileId, + authorId: user.profileId, title: 'titulo', content: 'Conteúdo', }); @@ -46,18 +46,18 @@ describe('/v1/comment (POST)', () => { const token = generateTestJwt({ sub: user.id, - userProfileId: user.userProfileId, + userProfileId: user.profileId, }); const idea = await ideaRepository.save({ - authorId: user.userProfileId, + authorId: user.profileId, title: 'titulo', content: 'Conteúdo', }); const comment = (await commentRepository.save({ content: 'Conteúdo', - authorId: user.userProfileId, + authorId: user.profileId, ideaId: idea.id, } as unknown as DeepPartial[])) as unknown as Comment; @@ -85,11 +85,11 @@ describe('/v1/comment (POST)', () => { const token = generateTestJwt({ sub: user.id, - userProfileId: user.userProfileId, + userProfileId: user.profileId, }); const idea = await ideaRepository.save({ - authorId: user.userProfileId, + authorId: user.profileId, title: 'titulo', content: 'Conteúdo', }); diff --git a/src/features/feed/comments/get-all-comments/v1/get-all-comments.feature.md b/src/features/feed/comments/get-all-comments/v1/get-all-comments.feature.md deleted file mode 100644 index 81e9192..0000000 --- a/src/features/feed/comments/get-all-comments/v1/get-all-comments.feature.md +++ /dev/null @@ -1,49 +0,0 @@ -# Feature: Get All Comments - -## Descrição -Lista comentários de uma ideia, com suporte a paginação. - -## Endpoint -`GET /v1/feed/ideas/{ideaId}/comments` - -## Path Parameters -- **ideaId**: UUID da ideia - -## Query Params (opcionais) -- **page**: número da página (default 1) -- **limit**: itens por página (default 10) - -## Response -```json -{ - "data": [ - { - "id": "uuid", - "content": "Great idea!", - "author": { "id": "uuid", "username": "user" }, - "createdAt": "2025-01-01T00:00:00.000Z" - } - ], - "pagination": { "page": 1, "limit": 10, "total": 1 } -} -``` - -## Regras de Negócio -- Ordenação por **data de criação** asc ou desc (default desc) - -## Validações -- **ideaId**: UUID válido -- **page/limit**: inteiros positivos - -## Tratamento de Erros -- **404**: Ideia não encontrada -- **400**: Parâmetros inválidos - -## Segurança -- Público - -## Dependências -- **Comment Entity**, **Idea Entity** -- **TypeORM** - - diff --git a/src/features/feed/comments/get-all-comments/v1/get-all-comments.spec.ts b/src/features/feed/comments/get-all-comments/v1/get-all-comments.spec.ts index f762bad..03873ff 100644 --- a/src/features/feed/comments/get-all-comments/v1/get-all-comments.spec.ts +++ b/src/features/feed/comments/get-all-comments/v1/get-all-comments.spec.ts @@ -13,7 +13,7 @@ describe('/v1/comment (GET)', () => { const idea = await createTestIdea( 'Test Idea Title', 'This is the content of the test idea', - user1.userProfileId, + user1.profileId, [], // no comments at creation ); @@ -24,29 +24,28 @@ describe('/v1/comment (GET)', () => { ); const tokenUser2 = generateTestJwt({ sub: user2.id, - userProfileId: user2.userProfileId, + userProfileId: user2.profileId, }); - const user3 = await createTestUser( + const user3 = await createTestUser( 'testuser3', 'test3@example.com', 'password12345', - ); - const tokenUser3 = generateTestJwt({ - sub: user3.id, - userProfileId: user3.userProfileId, - }); + ); + const tokenUser3 = generateTestJwt({ + sub: user3.id, + userProfileId: user3.profileId, + }); - const comment = await request(app.getHttpServer()) + await request(app.getHttpServer()) .post('/v1/comment') .set('Authorization', `Bearer ${tokenUser2}`) .send({ content: 'Comment', - author: user2.userProfileId, ideaId: idea.id, }); - // 1 comentário do user3 - await request(app.getHttpServer()) + // 1 comentário do user3 + await request(app.getHttpServer()) .post('/v1/comment') .set('Authorization', `Bearer ${tokenUser3}`) .send({ @@ -54,30 +53,32 @@ describe('/v1/comment (GET)', () => { ideaId: idea.id, }); - const response = await request(app.getHttpServer()) - .get(`/v1/comment?ideaId=${idea.id}`) - - expect(response.status).toEqual(200); - expect(response.body.data.length).toEqual(2); + const response = await request(app.getHttpServer()).get( + `/v1/comment?ideaId=${idea.id}`, + ); + expect(response.status).toEqual(200); + expect(response.body.data.length).toEqual(2); }); it('should return 400 when idea id is invalid', async () => { - const ideaId = '1'; - const response = await request(app.getHttpServer()) - .get(`/v1/comment?ideaId=${ideaId}`) + const ideaId = '1'; + const response = await request(app.getHttpServer()).get( + `/v1/comment?ideaId=${ideaId}`, + ); - expect(response.status).toBe(400); - expect(response.body.data).toBeUndefined(); - expect(response.body.message).toContain('INVALID_IDEA_ID'); + expect(response.status).toBe(400); + expect(response.body.data).toBeUndefined(); + expect(response.body.message).toContain('INVALID_IDEA_ID'); }); it('should return 404 when ideaId does not exist', async () => { - const ideaId = '1e9c20a4-3f8d-4b71-8c0e-92a15f0b6d2c'; - const response = await request(app.getHttpServer()) - .get(`/v1/comment?ideaId=${ideaId}`) + const ideaId = '1e9c20a4-3f8d-4b71-8c0e-92a15f0b6d2c'; + const response = await request(app.getHttpServer()).get( + `/v1/comment?ideaId=${ideaId}`, + ); - expect(response.status).toBe(404); - expect(response.body.message).toContain('IDEA_NOT_FOUND'); + expect(response.status).toBe(404); + expect(response.body.message).toContain('IDEA_NOT_FOUND'); }); }); diff --git a/src/features/feed/comments/get-comment-by-id/v1/get-comment-by-id.controller.ts b/src/features/feed/comments/get-comment-by-id/v1/get-comment-by-id.controller.ts new file mode 100644 index 0000000..22e727a --- /dev/null +++ b/src/features/feed/comments/get-comment-by-id/v1/get-comment-by-id.controller.ts @@ -0,0 +1,40 @@ +import { type RequestWithUserData } from 'src/jwt/jwt.strategy'; +import { + Controller, + Get, + HttpCode, + HttpStatus, + Logger, + Param, + Request, + Version, +} from '@nestjs/common'; +import { GetCommentByIdInput } from './get-comments-by-id.dto'; +import { Repository } from 'typeorm'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Comment, CommentDto } from 'src/entities/comment.entity'; +import { CommentNotFound } from 'src/shared/errors'; + +@Controller('/comment') +export class GetCommentByIdController { + constructor( + @InjectRepository(Comment) + private readonly commentRepository: Repository, + ) {} + readonly logger = new Logger(this.constructor.name); + + @Get('/:id') + @Version('1') + @HttpCode(HttpStatus.OK) + async getCommentById( + @Request() req: RequestWithUserData, + @Param() { id }: GetCommentByIdInput, + ): Promise { + const comment = await this.commentRepository.findOne({ + where: { id }, + relations: ['author', 'author.user', 'replies', 'likes'], + }); + if (!comment) throw new CommentNotFound(); + return comment.toDto(req.user?.userProfileId); + } +} diff --git a/src/features/feed/comments/get-comment-by-id/v1/get-comments-by-id.dto.ts b/src/features/feed/comments/get-comment-by-id/v1/get-comments-by-id.dto.ts new file mode 100644 index 0000000..29f7ecf --- /dev/null +++ b/src/features/feed/comments/get-comment-by-id/v1/get-comments-by-id.dto.ts @@ -0,0 +1,6 @@ +import { IsUUID } from 'class-validator'; + +export class GetCommentByIdInput { + @IsUUID(undefined, { message: 'INVALID_ID' }) + id: string; +} diff --git a/src/features/feed/ideas/create-idea/v1/create-idea.controller.ts b/src/features/feed/ideas/create-idea/v1/create-idea.controller.ts index 792f72d..d81b0fc 100644 --- a/src/features/feed/ideas/create-idea/v1/create-idea.controller.ts +++ b/src/features/feed/ideas/create-idea/v1/create-idea.controller.ts @@ -5,34 +5,36 @@ import { HttpStatus, Logger, Post, + Query, Request, UploadedFiles, UseInterceptors, Version, } from '@nestjs/common'; -import { Protected } from 'src/shared/decorators/protected.decorator'; +import { ConfigService } from '@nestjs/config'; +import { FilesInterceptor } from '@nestjs/platform-express'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Idea, IdeaDto } from 'src/entities/idea.entity'; +import { Project } from 'src/entities/project.entity'; +import { Tag } from 'src/entities/tag.entity'; import { type RequestWithUserData } from 'src/jwt/jwt.strategy'; -import { CreateIdeaInput } from './create-idea.dto'; -import { FilesInterceptor } from '@nestjs/platform-express'; +import { Protected } from 'src/shared/decorators/protected.decorator'; +import { AtLeastOneImage, ProjectNotFound } from 'src/shared/errors'; import { S3Gateway, fileInterceptorOptions, } from 'src/shared/gateways/s3.gateway'; -import { ConfigService } from '@nestjs/config'; -import { Idea, IdeaDto } from 'src/entities/idea.entity'; -import { Link } from 'src/entities/link.entity'; -import { Tag } from 'src/entities/tag.entity'; -import { Image } from 'src/entities/image.entity'; -import { AtLeastOneImage } from 'src/shared/errors'; -import { safeSave } from 'src/shared/utils/safeSave'; +import { safeSave } from 'src/shared/utils/safe-save.util'; +import { Repository } from 'typeorm'; +import { CreateIdeaInput, CreateIdeaQueryInput } from './create-idea.dto'; @Controller('/idea') export class CreateIdeaController { constructor( @InjectRepository(Idea) private readonly ideaRepository: Repository, + @InjectRepository(Project) + private readonly projectRepository: Repository, private readonly configService: ConfigService, private readonly s3Gateway: S3Gateway, ) {} @@ -45,6 +47,7 @@ export class CreateIdeaController { @UseInterceptors(FilesInterceptor('images', 8, fileInterceptorOptions)) async createIdea( @Request() { user: { sub: id, userProfileId } }: RequestWithUserData, + @Query() { projectId }: CreateIdeaQueryInput, @Body() { title, content, links, tags }: CreateIdeaInput, @UploadedFiles() images: Express.Multer.File[], @@ -55,12 +58,15 @@ export class CreateIdeaController { newIdea.content = content; newIdea.authorId = userProfileId; - const newLinks = links?.map((link) => { - const newLink = new Link(); - newLink.url = link; - return newLink; - }); - newIdea.links = newLinks ?? []; + if (projectId) { + const project = await this.projectRepository.findOne({ + where: { id: projectId }, + }); + if (!project) throw new ProjectNotFound(); + newIdea.projectId = project.id; + } + + newIdea.links = links ?? []; const newTags = tags?.map((tag) => { const newTag = new Tag(); @@ -70,7 +76,7 @@ export class CreateIdeaController { newIdea.tags = newTags ?? []; const idea = await safeSave(this.ideaRepository, newIdea); - const newImages: Image[] = []; + const newImages: string[] = []; for (const image of images ?? []) { const ideaImageRes = await this.s3Gateway.uploadFile({ bucketName: this.configService.get('PUBLIC_BUCKET_NAME')!, @@ -78,10 +84,7 @@ export class CreateIdeaController { file: image.buffer, }); if (typeof ideaImageRes !== 'object') continue; - const newImage = new Image(); - newImage.alt = `Image ${image.originalname}`; - newImage.src = ideaImageRes.fileUrl; - newImages.push(newImage); + newImages.push(ideaImageRes.fileUrl); } idea.images = newImages; diff --git a/src/features/feed/ideas/create-idea/v1/create-idea.dto.ts b/src/features/feed/ideas/create-idea/v1/create-idea.dto.ts index a596f5a..fcf960d 100644 --- a/src/features/feed/ideas/create-idea/v1/create-idea.dto.ts +++ b/src/features/feed/ideas/create-idea/v1/create-idea.dto.ts @@ -4,11 +4,18 @@ import { IsArray, IsOptional, IsString, + IsUUID, IsUrl, Matches, MaxLength, } from 'class-validator'; -import { parseArraySafe } from 'src/shared/utils/parseArraySafe'; +import { parseArraySafe } from 'src/shared/utils/parse-array-safe.util'; + +export class CreateIdeaQueryInput { + @IsOptional() + @IsUUID(undefined, { message: 'INVALID_PROJECT_ID' }) + projectId?: string; +} export class CreateIdeaInput { @IsString({ message: 'INVALID_TITLE' }) diff --git a/src/features/feed/ideas/create-idea/v1/create-idea.feature.md b/src/features/feed/ideas/create-idea/v1/create-idea.feature.md deleted file mode 100644 index 2e040ef..0000000 --- a/src/features/feed/ideas/create-idea/v1/create-idea.feature.md +++ /dev/null @@ -1,63 +0,0 @@ -# Feature: Create Idea - -## Descrição -Cria uma nova ideia na plataforma Gamix. Retorna a ideia criada com seus relacionamentos principais. - -## Endpoint -`POST /v1/feed/ideas` - -## Request Body -```json -{ - "title": "My awesome idea", - "description": "A brief description about the idea", - "imageIds": ["uuid"], - "tagIds": ["uuid", "uuid"] -} -``` - -## Response -```json -{ - "id": "uuid", - "title": "My awesome idea", - "description": "A brief description about the idea", - "images": [], - "tags": [], - "author": { - "id": "uuid", - "username": "username" - }, - "likes": 0, - "views": 0, - "createdAt": "2025-01-01T00:00:00.000Z", - "updatedAt": "2025-01-01T00:00:00.000Z" -} -``` - -## Regras de Negócio -- **Autenticação obrigatória** para criar ideias -- **Título** obrigatório e único por autor -- **Descrição** obrigatória -- **Tags** e **imagens** opcionais - -## Validações -- **title**: string, 3-255 caracteres, obrigatório -- **description**: string, 3-2000 caracteres, obrigatório -- **imageIds/tagIds**: arrays de UUIDs válidos (opcionais) - -## Tratamento de Erros -- **400**: Payload inválido -- **401**: Não autenticado -- **409**: Título já utilizado pelo mesmo autor - -## Segurança -- **JWT** obrigatório -- **Sanitização** de strings para prevenir XSS/SQLi - -## Dependências -- **Idea Entity**, **Tag Entity**, **Image Entity**, **User Entity** -- **TypeORM**: persistência -- **Protected Decorator** e **JWT Strategy** - - diff --git a/src/features/feed/ideas/delete-idea/v1/delete-idea.controller.ts b/src/features/feed/ideas/delete-idea/v1/delete-idea.controller.ts index b075b8c..7847a96 100644 --- a/src/features/feed/ideas/delete-idea/v1/delete-idea.controller.ts +++ b/src/features/feed/ideas/delete-idea/v1/delete-idea.controller.ts @@ -41,12 +41,10 @@ export class DeleteIdeaController { relations: [ 'author', 'author.user', - 'images', 'tags', 'comments', 'likes', 'views', - 'links', ], }); if (!idea) throw new IdeaNotFound(); diff --git a/src/features/feed/ideas/delete-idea/v1/delete-idea.feature.md b/src/features/feed/ideas/delete-idea/v1/delete-idea.feature.md deleted file mode 100644 index 5451e46..0000000 --- a/src/features/feed/ideas/delete-idea/v1/delete-idea.feature.md +++ /dev/null @@ -1,45 +0,0 @@ -# Feature: Delete Idea - -## Descrição -Remove uma ideia existente do usuário autenticado, incluindo relacionamentos dependentes. - -## Endpoint -`DELETE /v1/feed/ideas/{id}` - -## Path Parameters -- **id**: UUID da ideia - -## Response -```json -{ - "message": "Idea deleted successfully", - "deletedIdea": { - "id": "uuid", - "title": "Deleted title" - } -} -``` - -## Regras de Negócio -- **Autenticação obrigatória** -- Apenas o **autor** pode deletar -- Deleção em **cascata** de likes, views e comentários - -## Validações -- **id**: UUID válido - -## Tratamento de Erros -- **401**: Não autenticado -- **403**: Usuário não é autor da ideia -- **404**: Ideia não encontrada - -## Segurança -- **JWT** obrigatório -- **Controle de acesso** por propriedade - -## Dependências -- **Idea/Comment/Like/View Entities** -- **TypeORM** com cascata -- **Protected Decorator**, **JWT Strategy** - - diff --git a/src/features/feed/ideas/get-all-ideas/v1/get-all-ideas.controller.ts b/src/features/feed/ideas/get-all-ideas/v1/get-all-ideas.controller.ts index ae7887b..c61f89c 100644 --- a/src/features/feed/ideas/get-all-ideas/v1/get-all-ideas.controller.ts +++ b/src/features/feed/ideas/get-all-ideas/v1/get-all-ideas.controller.ts @@ -1,4 +1,3 @@ -import { type RequestWithUserData } from 'src/jwt/jwt.strategy'; import { Controller, Get, @@ -9,10 +8,11 @@ import { Request, Version, } from '@nestjs/common'; -import { GetAllIdeasOutput, GetAllIdeasInput } from './get-all-ideas.dto'; -import { Repository } from 'typeorm'; import { InjectRepository } from '@nestjs/typeorm'; import { Idea } from 'src/entities/idea.entity'; +import { type RequestWithUserData } from 'src/jwt/jwt.strategy'; +import { Repository } from 'typeorm'; +import { GetAllIdeasInput, GetAllIdeasOutput } from './get-all-ideas.dto'; @Controller('/idea') export class GetAllIdeasController { @@ -27,18 +27,16 @@ export class GetAllIdeasController { @HttpCode(HttpStatus.OK) async getAllIdeas( @Request() req: RequestWithUserData, - @Query() { quantity, page, authorId }: GetAllIdeasInput, + @Query() { quantity, page, authorId, projectId }: GetAllIdeasInput, ): Promise { const qb = this.ideaRepository .createQueryBuilder('idea') .leftJoinAndSelect('idea.author', 'author') .leftJoinAndSelect('author.user', 'user') - .leftJoinAndSelect('idea.images', 'images') .leftJoinAndSelect('idea.tags', 'tags') .leftJoinAndSelect('idea.comments', 'comments') .leftJoinAndSelect('idea.likes', 'likes') - .leftJoinAndSelect('idea.views', 'views') - .leftJoinAndSelect('idea.links', 'links'); + .leftJoinAndSelect('idea.views', 'views'); if (authorId) { qb.andWhere('idea.authorId = :authorId', { authorId }); @@ -48,6 +46,12 @@ export class GetAllIdeasController { }); } + if (projectId) { + qb.andWhere('idea.projectId = :projectId', { projectId }); + } else { + qb.andWhere('idea.projectId IS NULL'); + } + qb.skip(page * quantity).take(quantity); const [ideas, total] = await qb.getManyAndCount(); diff --git a/src/features/feed/ideas/get-all-ideas/v1/get-all-ideas.dto.ts b/src/features/feed/ideas/get-all-ideas/v1/get-all-ideas.dto.ts index f033261..b149deb 100644 --- a/src/features/feed/ideas/get-all-ideas/v1/get-all-ideas.dto.ts +++ b/src/features/feed/ideas/get-all-ideas/v1/get-all-ideas.dto.ts @@ -18,6 +18,10 @@ export class GetAllIdeasInput { @IsOptional() @IsUUID(undefined, { message: 'INVALID_AUTHOR_ID' }) authorId?: string; + + @IsOptional() + @IsUUID(undefined, { message: 'INVALID_PROJECT_ID' }) + projectId?: string; } export class GetAllIdeasOutput { diff --git a/src/features/feed/ideas/get-all-ideas/v1/get-all-ideas.feature.md b/src/features/feed/ideas/get-all-ideas/v1/get-all-ideas.feature.md deleted file mode 100644 index 3d260f5..0000000 --- a/src/features/feed/ideas/get-all-ideas/v1/get-all-ideas.feature.md +++ /dev/null @@ -1,53 +0,0 @@ -# Feature: Get All Ideas - -## Descrição -Lista ideias paginadas com filtros opcionais, incluindo metadados de likes e views. - -## Endpoint -`GET /v1/feed/ideas` - -## Query Params (opcionais) -- **page**: número da página (default 1) -- **limit**: itens por página (default 10) -- **tags**: lista de UUIDs (CSV) -- **authorId**: filtra por autor (UUID) -- **search**: termo de busca em título/descrição - -## Response -```json -{ - "data": [ - { - "id": "uuid", - "title": "Idea title", - "description": "...", - "likes": 5, - "views": 20, - "author": { "id": "uuid", "username": "user" }, - "tags": [] - } - ], - "pagination": { "page": 1, "limit": 10, "total": 1 } -} -``` - -## Regras de Negócio -- Ordenação por **data de criação** desc por padrão -- Suporta **paginação** e **filtros** - -## Validações -- **page/limit**: inteiros positivos -- **tags**: CSV de UUIDs válidos -- **authorId**: UUID válido - -## Tratamento de Erros -- **400**: Parâmetros inválidos - -## Segurança -- Público, mas pode retornar campos diferentes se autenticado - -## Dependências -- **Idea/Tag/User Entities** -- **parseArraySafe.ts** para parse de listas - - diff --git a/src/features/feed/ideas/get-idea-by-id/v1/get-idea-by-id.controller.ts b/src/features/feed/ideas/get-idea-by-id/v1/get-idea-by-id.controller.ts index 0d1c780..64f9317 100644 --- a/src/features/feed/ideas/get-idea-by-id/v1/get-idea-by-id.controller.ts +++ b/src/features/feed/ideas/get-idea-by-id/v1/get-idea-by-id.controller.ts @@ -35,12 +35,10 @@ export class GetIdeaByIdController { relations: [ 'author', 'author.user', - 'images', 'tags', 'comments', 'likes', 'views', - 'links', ], }); if (!idea) throw new IdeaNotFound(); diff --git a/src/features/feed/ideas/get-idea-by-id/v1/get-idea-by-id.feature.md b/src/features/feed/ideas/get-idea-by-id/v1/get-idea-by-id.feature.md deleted file mode 100644 index f65d6fe..0000000 --- a/src/features/feed/ideas/get-idea-by-id/v1/get-idea-by-id.feature.md +++ /dev/null @@ -1,43 +0,0 @@ -# Feature: Get Idea By Id - -## Descrição -Retorna os detalhes de uma ideia pelo seu ID, incluindo autor, tags, imagens e métricas. - -## Endpoint -`GET /v1/feed/ideas/{id}` - -## Path Parameters -- **id**: UUID da ideia - -## Response -```json -{ - "id": "uuid", - "title": "Idea title", - "description": "...", - "images": [], - "tags": [], - "author": { "id": "uuid", "username": "user" }, - "likes": 5, - "views": 20, - "createdAt": "2025-01-01T00:00:00.000Z" -} -``` - -## Regras de Negócio -- Inclui relacionamentos essenciais (autor, tags, imagens) - -## Validações -- **id**: UUID válido - -## Tratamento de Erros -- **404**: Ideia não encontrada - -## Segurança -- Público - -## Dependências -- **Idea/Image/Tag/User Entities** -- **TypeORM** para carregamento de relacionamentos - - diff --git a/src/features/feed/ideas/record-view/v1/record-view.controller.ts b/src/features/feed/ideas/record-view/v1/record-view.controller.ts index d10bb4b..05d265b 100644 --- a/src/features/feed/ideas/record-view/v1/record-view.controller.ts +++ b/src/features/feed/ideas/record-view/v1/record-view.controller.ts @@ -14,7 +14,7 @@ import { View } from 'src/entities/view.entity'; import { Repository } from 'typeorm'; import { RecordViewInput } from './record-view.dto'; import { type RequestWithUserData } from 'src/jwt/jwt.strategy'; -import { safeSave } from 'src/shared/utils/safeSave'; +import { safeSave } from 'src/shared/utils/safe-save.util'; @Controller('/view') export class RecordViewController { @@ -34,7 +34,7 @@ export class RecordViewController { ) { await safeSave(this.viewRepository, { ideaId: targetIdeaId, - userProfileId, + profileId: userProfileId, }); } } diff --git a/src/features/feed/ideas/record-view/v1/record-view.feature.md b/src/features/feed/ideas/record-view/v1/record-view.feature.md deleted file mode 100644 index 2754b7d..0000000 --- a/src/features/feed/ideas/record-view/v1/record-view.feature.md +++ /dev/null @@ -1,45 +0,0 @@ -# Feature: Record View - -## Descrição -Registra uma visualização para uma ideia. Pode considerar o usuário autenticado ou um identificador anônimo. - -## Endpoint -`POST /v1/feed/ideas/{id}/view` - -## Path Parameters -- **id**: UUID da ideia - -## Request Body (opcional) -```json -{ - "anonymousId": "string-optional" -} -``` - -## Response -```json -{ - "id": "uuid", - "views": 21 -} -``` - -## Regras de Negócio -- Incrementa contador de **views** da ideia -- Pode deduplicar por **usuário** autenticado ou **anonymousId** (quando aplicável) - -## Validações -- **id**: UUID válido -- **anonymousId**: string (opcional) - -## Tratamento de Erros -- **404**: Ideia não encontrada - -## Segurança -- Público - -## Dependências -- **View Entity**, **Idea Entity** -- **TypeORM** - - diff --git a/src/features/feed/ideas/update-idea/v1/update-idea.controller.ts b/src/features/feed/ideas/update-idea/v1/update-idea.controller.ts index 5bf5fef..5d3c4fc 100644 --- a/src/features/feed/ideas/update-idea/v1/update-idea.controller.ts +++ b/src/features/feed/ideas/update-idea/v1/update-idea.controller.ts @@ -23,12 +23,10 @@ import { } from 'src/shared/gateways/s3.gateway'; import { ConfigService } from '@nestjs/config'; import { Idea, IdeaDto } from 'src/entities/idea.entity'; -import { Link } from 'src/entities/link.entity'; import { Tag } from 'src/entities/tag.entity'; -import { Image } from 'src/entities/image.entity'; import { AtLeastOneImage, IdeaNotFound } from 'src/shared/errors'; import { GetIdeaByIdInput } from '../../get-idea-by-id/v1/get-idea-by-id.dto'; -import { safeSave } from 'src/shared/utils/safeSave'; +import { safeSave } from 'src/shared/utils/safe-save.util'; @Controller('/idea') export class UpdateIdeaController { @@ -58,12 +56,10 @@ export class UpdateIdeaController { relations: [ 'author', 'author.user', - 'images', 'tags', 'comments', 'likes', 'views', - 'links', ], }); if (!idea) throw new IdeaNotFound(); @@ -73,15 +69,7 @@ export class UpdateIdeaController { idea.authorId = userProfileId; if (links) { - await this.ideaRepository.manager.delete(Link, { ideaId: idea.id }); - - const newLinks = links.map((link) => { - const newLink = new Link(); - newLink.url = link; - newLink.ideaId = idea.id; - return newLink; - }); - idea.links = newLinks; + idea.links = links; } if (tags) { @@ -96,21 +84,15 @@ export class UpdateIdeaController { idea.tags = newTags; } - const newImages: Image[] = []; + const newImages: string[] = []; for (const image of images ?? []) { - await this.ideaRepository.manager.delete(Image, { ideaId: idea.id }); - const ideaImageRes = await this.s3Gateway.uploadFile({ bucketName: this.configService.get('PUBLIC_BUCKET_NAME')!, key: `user-${id}/ideas/${idea.id}/${image.originalname}`, file: image.buffer, }); if (typeof ideaImageRes !== 'object') continue; - const newImage = new Image(); - newImage.alt = `Image ${image.originalname}`; - newImage.src = ideaImageRes.fileUrl; - newImage.ideaId = idea.id; - newImages.push(newImage); + newImages.push(ideaImageRes.fileUrl); } idea.images = newImages; diff --git a/src/features/feed/ideas/update-idea/v1/update-idea.dto.ts b/src/features/feed/ideas/update-idea/v1/update-idea.dto.ts index 57eacc8..f5dfc82 100644 --- a/src/features/feed/ideas/update-idea/v1/update-idea.dto.ts +++ b/src/features/feed/ideas/update-idea/v1/update-idea.dto.ts @@ -8,7 +8,7 @@ import { Matches, MaxLength, } from 'class-validator'; -import { parseArraySafe } from 'src/shared/utils/parseArraySafe'; +import { parseArraySafe } from 'src/shared/utils/parse-array-safe.util'; export class UpdateIdeaInput { @IsString({ message: 'INVALID_TITLE' }) diff --git a/src/features/feed/ideas/update-idea/v1/update-idea.feature.md b/src/features/feed/ideas/update-idea/v1/update-idea.feature.md deleted file mode 100644 index c7c432a..0000000 --- a/src/features/feed/ideas/update-idea/v1/update-idea.feature.md +++ /dev/null @@ -1,59 +0,0 @@ -# Feature: Update Idea - -## Descrição -Atualiza os dados de uma ideia existente do usuário autenticado. - -## Endpoint -`PUT /v1/feed/ideas/{id}` - -## Path Parameters -- **id**: UUID da ideia - -## Request Body -```json -{ - "title": "Updated title", - "description": "Updated description", - "imageIds": ["uuid"], - "tagIds": ["uuid"] -} -``` - -## Response -```json -{ - "id": "uuid", - "title": "Updated title", - "description": "Updated description", - "images": [], - "tags": [], - "updatedAt": "2025-01-01T00:00:00.000Z" -} -``` - -## Regras de Negócio -- **Autenticação obrigatória** -- Apenas o **autor** pode atualizar a ideia -- **Título** continua único por autor - -## Validações -- **id**: UUID válido -- **title/description**: strings válidas (mesmos critérios de criação) -- **imageIds/tagIds**: arrays de UUIDs válidos (opcionais) - -## Tratamento de Erros -- **400**: Payload inválido -- **401**: Não autenticado -- **403**: Usuário não é autor da ideia -- **404**: Ideia não encontrada -- **409**: Título já utilizado pelo mesmo autor - -## Segurança -- **JWT** obrigatório -- **Controle de acesso** por propriedade do recurso - -## Dependências -- **Idea Entity**, **Tag Entity**, **Image Entity** -- **TypeORM**, **JWT Strategy**, **Protected Decorator** - - diff --git a/src/features/feed/toggle-like/v1/toggle-like.controller.ts b/src/features/feed/toggle-like/v1/toggle-like.controller.ts index 9d5c55a..54cbb08 100644 --- a/src/features/feed/toggle-like/v1/toggle-like.controller.ts +++ b/src/features/feed/toggle-like/v1/toggle-like.controller.ts @@ -14,7 +14,7 @@ import { Repository } from 'typeorm'; import { type RequestWithUserData } from 'src/jwt/jwt.strategy'; import { LikeType, ToggleLikeInput } from './toggle-like.dto'; import { Like } from 'src/entities/like.entity'; -import { safeSave } from 'src/shared/utils/safeSave'; +import { safeSave } from 'src/shared/utils/safe-save.util'; @Controller('/like') export class ToggleLikeController { @@ -33,8 +33,8 @@ export class ToggleLikeController { @Body() { targetLikeId, likeType }: ToggleLikeInput, ): Promise { const targets = { - [LikeType.IDEA]: { userProfileId, ideaId: targetLikeId }, - [LikeType.COMMENT]: { userProfileId, commentId: targetLikeId }, + [LikeType.IDEA]: { profileId: userProfileId, ideaId: targetLikeId }, + [LikeType.COMMENT]: { profileId: userProfileId, commentId: targetLikeId }, }; const targetLike = await this.likeRepository.findOne({ diff --git a/src/features/feed/toggle-like/v1/toggle-like.feature.md b/src/features/feed/toggle-like/v1/toggle-like.feature.md deleted file mode 100644 index ef4e37c..0000000 --- a/src/features/feed/toggle-like/v1/toggle-like.feature.md +++ /dev/null @@ -1,38 +0,0 @@ -# Feature: Toggle Like - -## Descrição -Alterna o estado de like do usuário autenticado para uma ideia específica. - -## Endpoint -`POST /v1/feed/ideas/{id}/toggle-like` - -## Path Parameters -- **id**: UUID da ideia - -## Response -```json -{ - "id": "uuid", - "liked": true, - "likes": 6 -} -``` - -## Regras de Negócio -- **Autenticação obrigatória** -- Alterna entre **like** e **unlike** -- Atualiza contador agregado de likes - -## Validações -- **id**: UUID válido - -## Tratamento de Erros -- **401**: Não autenticado -- **404**: Ideia não encontrada - -## Segurança -- **JWT** obrigatório - -## Dependências -- **Like Entity**, **Idea Entity**, **User Entity** -- **TypeORM**, **Protected Decorator**, **JWT Strategy** diff --git a/src/features/get-health-status/v1/get-health-status.feature.md b/src/features/get-health-status/v1/get-health-status.feature.md deleted file mode 100644 index fd5821b..0000000 --- a/src/features/get-health-status/v1/get-health-status.feature.md +++ /dev/null @@ -1,57 +0,0 @@ -# Feature: Get Health Status - -## Descrição -Endpoint para verificar o status de saúde da aplicação Gamix. Retorna informações básicas sobre o estado do serviço. - -## Endpoint -`GET /v1/health` - -## Response -```json -{ - "status": "ok" -} -``` - -## Regras de Negócio -- **Endpoint público** (não requer autenticação) -- **Resposta simples** com status da aplicação -- **Verificação básica** de funcionamento -- **Logging** de tempo de resposta - -## Validações -- **Nenhuma validação** específica necessária -- **Endpoint sempre disponível** quando a aplicação está rodando - -## Tratamento de Erros -- **200**: Sempre retorna sucesso quando a aplicação está funcionando -- **500**: Apenas em caso de falha crítica da aplicação - -## Segurança -- **Endpoint público** sem restrições -- **Sem dados sensíveis** na resposta -- **Apenas status** básico da aplicação - -## Dependências -- **Nenhuma dependência** externa -- **Aplicação NestJS** funcionando -- **Logging decorator** para monitoramento - -## Casos de Uso -- **Health check** de load balancers -- **Monitoramento** de aplicação -- **Verificação** de disponibilidade -- **CI/CD** pipelines -- **Kubernetes** liveness/readiness probes - -## Monitoramento -- **Tempo de resposta** registrado -- **Logs** de acesso ao endpoint -- **Métricas** de disponibilidade -- **Alertas** em caso de falha - -## Integração -- **Load balancers** podem usar para health checks -- **Orquestradores** podem verificar disponibilidade -- **Monitoramento** externo pode consultar -- **CI/CD** pode verificar antes de deploy diff --git a/src/features/notifications/get-all-notifications/v1/get-all-notifications.controller.ts b/src/features/notifications/get-all-notifications/v1/get-all-notifications.controller.ts new file mode 100644 index 0000000..f340f55 --- /dev/null +++ b/src/features/notifications/get-all-notifications/v1/get-all-notifications.controller.ts @@ -0,0 +1,74 @@ +import { + Controller, + Get, + HttpCode, + HttpStatus, + Logger, + Query, + Request, + Version, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Notification } from 'src/entities/notification.entity'; +import { type RequestWithUserData } from 'src/jwt/jwt.strategy'; +import { Protected } from 'src/shared/decorators/protected.decorator'; +import { Repository } from 'typeorm'; +import { + GetAllNotificationsInput, + GetAllNotificationsOutput, +} from './get-all-notifications.dto'; + +@Controller('/notification') +export class GetAllNotificationsController { + constructor( + @InjectRepository(Notification) + private readonly notificationRepository: Repository, + ) {} + readonly logger = new Logger(this.constructor.name); + + @Get() + @Version('1') + @HttpCode(HttpStatus.OK) + @Protected() + async getAllNotifications( + @Request() req: RequestWithUserData, + @Query() { quantity, page, isRead, type }: GetAllNotificationsInput, + ): Promise { + const qb = this.notificationRepository + .createQueryBuilder('notification') + .leftJoinAndSelect('notification.actorProfile', 'actorProfile') + .leftJoinAndSelect('actorProfile.user', 'actorUser') + .where('notification.userProfileId = :userProfileId', { + userProfileId: req.user.userProfileId, + }); + + if (isRead !== undefined) { + if (isRead) { + qb.andWhere('notification.readAt IS NOT NULL'); + } else { + qb.andWhere('notification.readAt IS NULL'); + } + } + + if (type) { + qb.andWhere('notification.type = :type', { type }); + } + + qb.orderBy('notification.createdAt', 'DESC'); + + qb.skip(page * quantity).take(quantity); + + const [notifications, total] = await qb.getManyAndCount(); + + const pages = Math.ceil(total / quantity); + const nextPage = page + 1; + + return { + total, + pages, + page, + nextPage: nextPage >= pages ? null : nextPage, + data: notifications.map((notification) => notification.toDto()), + }; + } +} diff --git a/src/features/notifications/get-all-notifications/v1/get-all-notifications.dto.ts b/src/features/notifications/get-all-notifications/v1/get-all-notifications.dto.ts new file mode 100644 index 0000000..126af68 --- /dev/null +++ b/src/features/notifications/get-all-notifications/v1/get-all-notifications.dto.ts @@ -0,0 +1,41 @@ +import { Transform, Type } from 'class-transformer'; +import { IsBoolean, IsEnum, IsNumber, IsOptional, Min } from 'class-validator'; +import { + NotificationDto, + NotificationType, +} from 'src/entities/notification.entity'; + +export class GetAllNotificationsInput { + @IsOptional() + @IsNumber({}, { message: 'INVALID_PAGE' }) + @Min(0, { message: 'INVALID_PAGE' }) + @Type(() => Number) + page: number = 0; + + @Type(() => Number) + @IsNumber({}, { message: 'INVALID_QUANTITY' }) + @Min(1, { message: 'INVALID_QUANTITY' }) + @IsOptional() + quantity: number = 10; + + @IsOptional() + @IsBoolean({ message: 'INVALID_IS_READ' }) + @Transform(({ value }) => { + if (value === 'true') return true; + if (value === 'false') return false; + return value; + }) + isRead?: boolean; + + @IsOptional() + @IsEnum(NotificationType, { message: 'INVALID_NOTIFICATION_TYPE' }) + type?: NotificationType; +} + +export class GetAllNotificationsOutput { + page: number; + nextPage: number | null; + pages: number; + total: number; + data: NotificationDto[]; +} diff --git a/src/features/notifications/read/v1/read.controller.ts b/src/features/notifications/read/v1/read.controller.ts new file mode 100644 index 0000000..daba542 --- /dev/null +++ b/src/features/notifications/read/v1/read.controller.ts @@ -0,0 +1,47 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + Logger, + Patch, + Request, + Version, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Notification } from 'src/entities/notification.entity'; +import { type RequestWithUserData } from 'src/jwt/jwt.strategy'; +import { Protected } from 'src/shared/decorators/protected.decorator'; +import { FindOptionsWhere, In, IsNull, Repository } from 'typeorm'; +import { ReadNotificationsInput } from './read.dto'; + +@Controller('/notification') +export class ReadNotificationsController { + constructor( + @InjectRepository(Notification) + private readonly notificationRepository: Repository, + ) {} + readonly logger = new Logger(this.constructor.name); + + @Patch('/read') + @Version('1') + @Protected() + @HttpCode(HttpStatus.NO_CONTENT) + async readNotifications( + @Request() { user: { userProfileId } }: RequestWithUserData, + @Body() { notificationIds }: ReadNotificationsInput, + ): Promise { + const whereCondition: FindOptionsWhere = { + userProfileId: userProfileId, + readAt: IsNull(), + }; + + if (notificationIds && notificationIds.length > 0) { + whereCondition.id = In(notificationIds); + } + + await this.notificationRepository.update(whereCondition, { + readAt: new Date(), + }); + } +} diff --git a/src/features/notifications/read/v1/read.dto.ts b/src/features/notifications/read/v1/read.dto.ts new file mode 100644 index 0000000..aba9f1c --- /dev/null +++ b/src/features/notifications/read/v1/read.dto.ts @@ -0,0 +1,8 @@ +import { IsArray, IsOptional, IsUUID } from 'class-validator'; + +export class ReadNotificationsInput { + @IsOptional() + @IsArray({ message: 'INVALID_NOTIFICATION_IDS' }) + @IsUUID(undefined, { each: true, message: 'INVALID_NOTIFICATION_ID' }) + notificationIds?: string[]; +} diff --git a/src/features/project/delete-project/v1/delete-project.controller.ts b/src/features/project/delete-project/v1/delete-project.controller.ts new file mode 100644 index 0000000..6f75653 --- /dev/null +++ b/src/features/project/delete-project/v1/delete-project.controller.ts @@ -0,0 +1,54 @@ +import { + Controller, + Delete, + HttpCode, + HttpStatus, + Logger, + Param, + Request, + Version, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Project } from 'src/entities/project.entity'; +import { type RequestWithUserData } from 'src/jwt/jwt.strategy'; +import { Protected } from 'src/shared/decorators/protected.decorator'; +import { ProjectNotFound, YouAreNotTheOwner } from 'src/shared/errors'; +import { S3Gateway } from 'src/shared/gateways/s3.gateway'; +import { Repository } from 'typeorm'; +import { DeleteProjectInput } from './delete-project.dto'; + +@Controller('/project') +export class DeleteProjectController { + constructor( + @InjectRepository(Project) + private readonly projectRepository: Repository, + private readonly s3Gateway: S3Gateway, + private readonly configService: ConfigService, + ) {} + readonly logger = new Logger(this.constructor.name); + + @Delete('/:projectId') + @Version('1') + @Protected() + @HttpCode(HttpStatus.NO_CONTENT) + async deleteProject( + @Request() req: RequestWithUserData, + @Param() { projectId }: DeleteProjectInput, + ): Promise { + const project = await this.projectRepository.findOne({ + where: { id: projectId }, + }); + + if (!project) throw new ProjectNotFound(); + if (project.ownerId !== req.user.userProfileId) { + throw new YouAreNotTheOwner(); + } + + await this.s3Gateway.deleteFolder( + this.configService.get('PUBLIC_BUCKET_NAME')!, + `user-${req.user.sub}/project-${projectId}`, + ); + await this.projectRepository.delete(projectId); + } +} diff --git a/src/features/project/delete-project/v1/delete-project.dto.ts b/src/features/project/delete-project/v1/delete-project.dto.ts new file mode 100644 index 0000000..3a69003 --- /dev/null +++ b/src/features/project/delete-project/v1/delete-project.dto.ts @@ -0,0 +1,6 @@ +import { IsUUID } from 'class-validator'; + +export class DeleteProjectInput { + @IsUUID(undefined, { message: 'INVALID_PROJECT_ID' }) + projectId: string; +} diff --git a/src/features/project/get-all-projects/v1/get-all-projects.controller.ts b/src/features/project/get-all-projects/v1/get-all-projects.controller.ts new file mode 100644 index 0000000..b3aec74 --- /dev/null +++ b/src/features/project/get-all-projects/v1/get-all-projects.controller.ts @@ -0,0 +1,141 @@ +import { + Controller, + Get, + HttpCode, + HttpStatus, + Logger, + Query, + Request, + Version, +} from '@nestjs/common'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { Follow } from 'src/entities/follow.entity'; +import { Profile } from 'src/entities/profile.entity'; +import { Project } from 'src/entities/project.entity'; +import { type RequestWithUserData } from 'src/jwt/jwt.strategy'; +import { DataSource, Repository } from 'typeorm'; +import { + GetAllProjectsInput, + GetAllProjectsOutput, +} from './get-all-projects.dto'; + +@Controller('/project') +export class GetAllProjectsController { + constructor( + @InjectRepository(Project) + private readonly projectRepository: Repository, + @InjectDataSource() + private readonly dataSource: DataSource, + ) {} + readonly logger = new Logger(this.constructor.name); + + @Get() + @Version('1') + @HttpCode(HttpStatus.OK) + async getAllProjects( + @Request() req: RequestWithUserData, + @Query() { search, quantity, page }: GetAllProjectsInput, + ): Promise { + const qb = this.projectRepository + .createQueryBuilder('project') + .leftJoinAndSelect('project.owner', 'owner') + .leftJoinAndSelect('owner.user', 'user') + .leftJoinAndSelect('project.posts', 'posts'); + + if (search && search.trim() !== '') { + qb.andWhere( + '(project.title ILIKE :search OR project.description ILIKE :search)', + { search: `%${search}%` }, + ); + } + + qb.skip(page * quantity).take(quantity); + + const [projects, total] = await qb.getManyAndCount(); + const pages = Math.ceil(total / quantity); + const nextPage = page + 1; + + if (projects.length > 0) { + const projectIds = projects.map((p) => p.id); + + const topFollowersRaw = await this.dataSource.query( + ` + WITH follower_counts AS ( + SELECT + following_user_id as user_id, + COUNT(*) as count + FROM follows + GROUP BY following_user_id + ), + ranked_follows AS ( + SELECT + f.id, + f.following_project_id, + f.follower_user_id, + p.display_name, + p.icon, + COALESCE(fc.count, 0) as follower_count, + ROW_NUMBER() OVER ( + PARTITION BY f.following_project_id + ORDER BY COALESCE(fc.count, 0) DESC + ) as rn + FROM follows f + INNER JOIN profiles p ON p.id = f.follower_user_id + LEFT JOIN follower_counts fc ON fc.user_id = f.follower_user_id + WHERE f.following_project_id = ANY($1::uuid[]) + ) + SELECT + id, + following_project_id as "followingProjectId", + follower_user_id as "followerUserId", + display_name as "displayName", + icon + FROM ranked_follows + WHERE rn <= 3 + ORDER BY following_project_id, rn + `, + [projectIds], + ); + + const topFollowersByProject = new Map< + string, + Array<{ name: string; icon: string | null }> + >(); + + for (const row of topFollowersRaw as Array<{ + followingProjectId: string; + displayName: string; + icon: string | null; + }>) { + const projectId = row.followingProjectId; + if (!topFollowersByProject.has(projectId)) { + topFollowersByProject.set(projectId, []); + } + topFollowersByProject.get(projectId)!.push({ + name: row.displayName, + icon: row.icon, + }); + } + + for (const project of projects) { + const topFollowers = topFollowersByProject.get(project.id) ?? []; + project.followers = topFollowers.map((tf) => { + const follow = new Follow(); + follow.followerUser = { + displayName: tf.name, + icon: tf.icon, + } as Profile; + return follow; + }) as any; + } + } + + return { + total, + pages, + page, + nextPage: nextPage >= pages ? null : nextPage, + data: projects.map((project) => project.toDto()), + }; + } +} diff --git a/src/features/project/get-all-projects/v1/get-all-projects.dto.ts b/src/features/project/get-all-projects/v1/get-all-projects.dto.ts new file mode 100644 index 0000000..f540548 --- /dev/null +++ b/src/features/project/get-all-projects/v1/get-all-projects.dto.ts @@ -0,0 +1,29 @@ +import { Type } from 'class-transformer'; +import { IsNumber, IsOptional, IsString, Min } from 'class-validator'; +import { ProjectDto } from 'src/entities/project.entity'; + +export class GetAllProjectsInput { + @IsOptional() + @IsNumber({}, { message: 'INVALID_PAGE' }) + @Min(0, { message: 'INVALID_PAGE' }) + @Type(() => Number) + page: number = 0; + + @Type(() => Number) + @IsNumber({}, { message: 'INVALID_QUANTITY' }) + @Min(1, { message: 'INVALID_QUANTITY' }) + @IsOptional() + quantity: number = 10; + + @IsString({ message: 'INVALID_SEARCH' }) + @IsOptional() + search?: string | undefined = ''; +} + +export class GetAllProjectsOutput { + page: number; + nextPage: number | null; + pages: number; + total: number; + data: ProjectDto[]; +} diff --git a/src/features/project/get-project/v1/get-project.controller.ts b/src/features/project/get-project/v1/get-project.controller.ts new file mode 100644 index 0000000..65bcc08 --- /dev/null +++ b/src/features/project/get-project/v1/get-project.controller.ts @@ -0,0 +1,96 @@ +import { + Controller, + Get, + HttpCode, + HttpStatus, + Logger, + Param, + Request, + Version, +} from '@nestjs/common'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { Follow } from 'src/entities/follow.entity'; +import { Profile } from 'src/entities/profile.entity'; +import { Project, ProjectDto } from 'src/entities/project.entity'; +import { type RequestWithUserData } from 'src/jwt/jwt.strategy'; +import { ProjectNotFound } from 'src/shared/errors'; +import { DataSource, Repository } from 'typeorm'; +import { GetProjectInput } from './get-project.dto'; + +@Controller('/project') +export class GetProjectController { + constructor( + @InjectRepository(Project) + private readonly projectRepository: Repository, + @InjectDataSource() + private readonly dataSource: DataSource, + ) {} + readonly logger = new Logger(this.constructor.name); + + @Get('/:projectId') + @Version('1') + @HttpCode(HttpStatus.OK) + async getProject( + @Request() req: RequestWithUserData, + @Param() { projectId }: GetProjectInput, + ): Promise { + const project = await this.projectRepository.findOne({ + where: { id: projectId }, + relations: ['owner', 'owner.user', 'posts'], + }); + + if (!project) throw new ProjectNotFound(); + + const topFollowersRaw = await this.dataSource.query( + ` + WITH follower_counts AS ( + SELECT + following_user_id as user_id, + COUNT(*) as count + FROM follows + GROUP BY following_user_id + ), + ranked_follows AS ( + SELECT + f.id, + f.follower_user_id, + p.display_name, + p.icon, + COALESCE(fc.count, 0) as follower_count, + ROW_NUMBER() OVER ( + ORDER BY COALESCE(fc.count, 0) DESC + ) as rn + FROM follows f + INNER JOIN profiles p ON p.id = f.follower_user_id + LEFT JOIN follower_counts fc ON fc.user_id = f.follower_user_id + WHERE f.following_project_id = $1::uuid + ) + SELECT + id, + follower_user_id as "followerUserId", + display_name as "displayName", + icon + FROM ranked_follows + WHERE rn <= 3 + ORDER BY rn + `, + [projectId], + ); + + project.followers = ( + topFollowersRaw as Array<{ + displayName: string; + icon: string | null; + }> + ).map((row) => { + const follow = new Follow(); + follow.followerUser = { + displayName: row.displayName, + icon: row.icon, + } as Profile; + return follow; + }); + + return project.toDto(); + } +} diff --git a/src/features/project/get-project/v1/get-project.dto.ts b/src/features/project/get-project/v1/get-project.dto.ts new file mode 100644 index 0000000..1676030 --- /dev/null +++ b/src/features/project/get-project/v1/get-project.dto.ts @@ -0,0 +1,6 @@ +import { IsUUID } from 'class-validator'; + +export class GetProjectInput { + @IsUUID(undefined, { message: 'INVALID_PROJECT_ID' }) + projectId: string; +} diff --git a/src/features/project/remove-member/v1/remove-member.controller.ts b/src/features/project/remove-member/v1/remove-member.controller.ts new file mode 100644 index 0000000..e838ae9 --- /dev/null +++ b/src/features/project/remove-member/v1/remove-member.controller.ts @@ -0,0 +1,76 @@ +import { + Controller, + Delete, + HttpCode, + HttpStatus, + Logger, + Param, + Request, + Version, +} from '@nestjs/common'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { Chat } from 'src/entities/chat.entity'; +import { Project } from 'src/entities/project.entity'; +import { type RequestWithUserData } from 'src/jwt/jwt.strategy'; +import { Protected } from 'src/shared/decorators/protected.decorator'; +import { + ProjectNotFound, + UserNotFound, + YouAreNotTheOwner, + YouCannotRemoveTheOwner, +} from 'src/shared/errors'; +import { DataSource, Repository } from 'typeorm'; +import { RemoveMemberInput } from './remove-member.dto'; + +@Controller('/project') +export class RemoveMemberController { + constructor( + @InjectRepository(Project) + private readonly projectRepository: Repository, + @InjectRepository(Chat) + private readonly chatRepository: Repository, + @InjectDataSource() + private readonly dataSource: DataSource, + ) {} + readonly logger = new Logger(this.constructor.name); + + @Delete('/:projectId/member/:memberId') + @Version('1') + @Protected() + @HttpCode(HttpStatus.NO_CONTENT) + async removeMember( + @Request() req: RequestWithUserData, + @Param() { projectId, memberId }: RemoveMemberInput, + ): Promise { + const project = await this.projectRepository.findOne({ + where: { id: projectId }, + }); + + if (!project) throw new ProjectNotFound(); + else if (project.ownerId !== req.user.userProfileId) { + throw new YouAreNotTheOwner(); + } else if (memberId === project.ownerId) { + throw new YouCannotRemoveTheOwner(); + } + + const memberExists = await this.dataSource.query( + `SELECT 1 FROM project_members WHERE project_id = $1::uuid AND profile_id = $2::uuid LIMIT 1`, + [projectId, memberId], + ); + if (memberExists.length === 0) throw new UserNotFound(); + + await this.projectRepository + .createQueryBuilder() + .relation(Project, 'members') + .of(project) + .remove(memberId); + + if (project.chatId) { + await this.chatRepository + .createQueryBuilder() + .relation(Chat, 'members') + .of(project.chatId) + .remove(memberId); + } + } +} diff --git a/src/features/project/remove-member/v1/remove-member.dto.ts b/src/features/project/remove-member/v1/remove-member.dto.ts new file mode 100644 index 0000000..e804265 --- /dev/null +++ b/src/features/project/remove-member/v1/remove-member.dto.ts @@ -0,0 +1,9 @@ +import { IsUUID } from 'class-validator'; + +export class RemoveMemberInput { + @IsUUID(undefined, { message: 'INVALID_PROJECT_ID' }) + projectId: string; + + @IsUUID(undefined, { message: 'INVALID_MEMBER_ID' }) + memberId: string; +} diff --git a/src/features/project/update-project/v1/update-project.controller.ts b/src/features/project/update-project/v1/update-project.controller.ts new file mode 100644 index 0000000..a6b1630 --- /dev/null +++ b/src/features/project/update-project/v1/update-project.controller.ts @@ -0,0 +1,105 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + Logger, + Param, + Patch, + Request, + UploadedFiles, + UseInterceptors, + Version, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { FileFieldsInterceptor } from '@nestjs/platform-express'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Project, ProjectDto } from 'src/entities/project.entity'; +import type { RequestWithUserData } from 'src/jwt/jwt.strategy'; +import { Protected } from 'src/shared/decorators/protected.decorator'; +import { PictureUpdateFail, YouAreNotTheOwner } from 'src/shared/errors'; +import { + S3Gateway, + SUPPORTED_IMAGES, + fileInterceptorOptions, +} from 'src/shared/gateways/s3.gateway'; +import { safeSave } from 'src/shared/utils/safe-save.util'; +import { Repository } from 'typeorm'; +import { + UpdateProjectInput, + UpdateProjectParamsInput, +} from './update-project.dto'; + +@Controller('/project') +export class UpdateProjectController { + constructor( + @InjectRepository(Project) + private readonly projectRepository: Repository, + private readonly configService: ConfigService, + private readonly s3Gateway: S3Gateway, + ) {} + readonly logger = new Logger(this.constructor.name); + + @Patch('/:projectId') + @Version('1') + @Protected() + @HttpCode(HttpStatus.OK) + @UseInterceptors( + FileFieldsInterceptor( + [ + { name: 'icon', maxCount: 1 }, + { name: 'backgroundImage', maxCount: 1 }, + ], + fileInterceptorOptions, + ), + ) + async updateProject( + @Request() { user: { sub: id, userProfileId } }: RequestWithUserData, + @Param() { projectId }: UpdateProjectParamsInput, + @Body() { title, description }: UpdateProjectInput, + @UploadedFiles() + files: { + icon?: Express.Multer.File[]; + backgroundImage?: Express.Multer.File[]; + }, + ): Promise { + const project = await this.projectRepository.findOne({ + where: { id: projectId }, + }); + + if (project?.ownerId !== userProfileId) throw new YouAreNotTheOwner(); + + const icon = files.icon?.[0]; + const backgroundImage = files.backgroundImage?.[0]; + + if (icon) { + const projectIconRes = await this.s3Gateway.uploadFile({ + bucketName: this.configService.get('PUBLIC_BUCKET_NAME')!, + key: `user-${id}/project-${projectId}/icon${SUPPORTED_IMAGES[icon.mimetype as keyof typeof SUPPORTED_IMAGES]}`, + file: icon.buffer, + }); + if (typeof projectIconRes !== 'object') throw new PictureUpdateFail(); + if (projectIconRes.fileUrl) project.icon = projectIconRes.fileUrl; + } + + if (backgroundImage) { + const projectbackgroundImageRes = await this.s3Gateway.uploadFile({ + bucketName: this.configService.get('PUBLIC_BUCKET_NAME')!, + key: `user-${id}/project-${projectId}/backgroundImage${SUPPORTED_IMAGES[backgroundImage.mimetype as keyof typeof SUPPORTED_IMAGES]}`, + file: backgroundImage.buffer, + }); + if (typeof projectbackgroundImageRes !== 'object') { + throw new PictureUpdateFail(); + } + if (projectbackgroundImageRes.fileUrl) { + project.backgroundImage = projectbackgroundImageRes.fileUrl; + } + } + + Object.assign(project, title && { title }, description && { description }); + + await safeSave(this.projectRepository, project); + + return project.toDto(); + } +} diff --git a/src/features/project/update-project/v1/update-project.dto.ts b/src/features/project/update-project/v1/update-project.dto.ts new file mode 100644 index 0000000..96f5af0 --- /dev/null +++ b/src/features/project/update-project/v1/update-project.dto.ts @@ -0,0 +1,18 @@ +import { IsOptional, IsString, IsUUID, Matches } from 'class-validator'; + +export class UpdateProjectParamsInput { + @IsUUID(undefined, { message: 'INVALID_PROJECT_ID' }) + projectId: string; +} + +export class UpdateProjectInput { + @IsString({ message: 'INVALID_TITLE' }) + @Matches(/^[^{}]{3,100}$/, { message: 'INVALID_TITLE' }) + @IsOptional() + title: string; + + @IsString({ message: 'INVALID_DESCRIPTION' }) + @Matches(/^[^{}]{3,300}$/, { message: 'INVALID_DESCRIPTION' }) + @IsOptional() + description: string; +} diff --git a/src/features/user-management/delete-user/v1/delete-user.controller.ts b/src/features/user-management/delete-user/v1/delete-user.controller.ts index 58b9401..6a15ecc 100644 --- a/src/features/user-management/delete-user/v1/delete-user.controller.ts +++ b/src/features/user-management/delete-user/v1/delete-user.controller.ts @@ -10,16 +10,16 @@ import { import { Protected } from 'src/shared/decorators/protected.decorator'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { User } from 'src/entities/user.entity'; import { type RequestWithUserData } from 'src/jwt/jwt.strategy'; import { S3Gateway } from 'src/shared/gateways/s3.gateway'; import { ConfigService } from '@nestjs/config'; +import { Profile } from 'src/entities/profile.entity'; @Controller('/user') export class DeleteUserController { constructor( - @InjectRepository(User) - private readonly userRepository: Repository, + @InjectRepository(Profile) + private readonly profileRepository: Repository, private readonly s3Gateway: S3Gateway, private readonly configService: ConfigService, ) {} @@ -34,6 +34,6 @@ export class DeleteUserController { this.configService.get('PUBLIC_BUCKET_NAME')!, `user-${req.user.sub}`, ); - await this.userRepository.delete(req.user.sub); + await this.profileRepository.delete(req.user.userProfileId); } } diff --git a/src/features/user-management/delete-user/v1/delete-user.feature.md b/src/features/user-management/delete-user/v1/delete-user.feature.md deleted file mode 100644 index 239a2fd..0000000 --- a/src/features/user-management/delete-user/v1/delete-user.feature.md +++ /dev/null @@ -1,72 +0,0 @@ -# Feature: Delete User - -## Descrição -Endpoint para deletar um usuário da plataforma Gamix. Remove permanentemente o usuário e todos os dados associados. - -## Endpoint -`DELETE /v1/user/{userProfileId}` - -## Path Parameters -- **userProfileId**: ID do perfil do usuário a ser deletado - -## Response -```json -{ - "message": "User deleted successfully", - "deletedUser": { - "id": "uuid", - "username": "username", - "email": "user@example.com" - } -} -``` - -## Regras de Negócio -- **Autenticação obrigatória** (JWT token) -- **Deleção permanente** do usuário e dados associados -- **Cascata** para relacionamentos (userProfile, links, follows) -- **Verificação** de existência do usuário antes da deleção -- **Log** da operação de deleção - -## Validações -- **JWT Token**: Deve ser válido e não expirado -- **userProfileId**: Deve ser um UUID válido -- **Usuário**: Deve existir no banco de dados - -## Tratamento de Erros -- **401**: Token JWT inválido ou expirado -- **404**: Usuário não encontrado -- **400**: Parâmetros inválidos -- **422**: Validação de campos falhou - -## Segurança -- **Autenticação JWT** obrigatória -- **Proteção** contra acesso não autorizado -- **Validação** de parâmetros de entrada -- **Confirmação** de existência do usuário - -## Dependências -- **User Entity**: Para busca e deleção do usuário -- **UserProfile Entity**: Para deleção em cascata -- **Link Entity**: Para deleção em cascata -- **Follow Entity**: Para deleção em cascata -- **JWT Strategy**: Para validação de token -- **Protected Decorator**: Para controle de acesso - -## Operações de Cascata -- **UserProfile**: Deletado automaticamente -- **Links**: Deletados automaticamente -- **Follows**: Relacionamentos deletados automaticamente -- **UserCode**: Código único deletado automaticamente - -## Logs -- **Operação**: Registro da deleção -- **Usuário**: ID e informações do usuário deletado -- **Timestamp**: Data e hora da operação -- **Resultado**: Sucesso ou falha da operação - -## Casos de Uso -- **Deleção de conta** pelo próprio usuário -- **Moderação** de usuários inapropriados -- **Limpeza** de dados de teste -- **GDPR** - Direito ao esquecimento diff --git a/src/features/user-management/delete-user/v1/delete-user.spec.ts b/src/features/user-management/delete-user/v1/delete-user.spec.ts index a05e01b..ac001e8 100644 --- a/src/features/user-management/delete-user/v1/delete-user.spec.ts +++ b/src/features/user-management/delete-user/v1/delete-user.spec.ts @@ -14,7 +14,10 @@ describe('/v1/user/{userProfileId} (DELETE)', () => { 'current@example.com', 'password123', ); - const token = generateTestJwt({ sub: currentUser.id }); + const token = generateTestJwt({ + sub: currentUser.id, + userProfileId: currentUser.profileId, + }); const response = await request(app.getHttpServer()) .delete('/v1/user/') @@ -62,8 +65,10 @@ describe('/v1/user/{userProfileId} (DELETE)', () => { 'current@example.com', 'password123', ); - const token = generateTestJwt({ sub: currentUser.id }); - + const token = generateTestJwt({ + sub: currentUser.id, + userProfileId: currentUser.profileId, + }); const response = await request(app.getHttpServer()) .delete('/v1/user/') .set('Authorization', `Bearer ${token}`); @@ -82,8 +87,10 @@ describe('/v1/user/{userProfileId} (DELETE)', () => { 'current@example.com', 'password123', ); - const token = generateTestJwt({ sub: currentUser.id }); - + const token = generateTestJwt({ + sub: currentUser.id, + userProfileId: currentUser.profileId, + }); const response = await request(app.getHttpServer()) .delete('/v1/user/') .set('Authorization', `Bearer ${token}`); @@ -108,8 +115,10 @@ describe('/v1/user/{userProfileId} (DELETE)', () => { 'current@example.com', 'password123', ); - const token = generateTestJwt({ sub: currentUser.id }); - + const token = generateTestJwt({ + sub: currentUser.id, + userProfileId: currentUser.profileId, + }); const response = await request(app.getHttpServer()) .delete('/v1/user/') .set('Authorization', `Bearer ${token}`); @@ -129,8 +138,10 @@ describe('/v1/user/{userProfileId} (DELETE)', () => { 'current@example.com', 'password123', ); - const token = generateTestJwt({ sub: currentUser.id }); - + const token = generateTestJwt({ + sub: currentUser.id, + userProfileId: currentUser.profileId, + }); const response = await request(app.getHttpServer()) .delete('/v1/user/') .set('Authorization', `Bearer ${token}`); @@ -150,8 +161,10 @@ describe('/v1/user/{userProfileId} (DELETE)', () => { 'current@example.com', 'password123', ); - const token = generateTestJwt({ sub: currentUser.id }); - + const token = generateTestJwt({ + sub: currentUser.id, + userProfileId: currentUser.profileId, + }); const response = await request(app.getHttpServer()) .delete('/v1/user/') .set('Authorization', `Bearer ${token}`); @@ -165,8 +178,10 @@ describe('/v1/user/{userProfileId} (DELETE)', () => { 'current@example.com', 'password123', ); - const token = generateTestJwt({ sub: currentUser.id }); - + const token = generateTestJwt({ + sub: currentUser.id, + userProfileId: currentUser.profileId, + }); const response = await request(app.getHttpServer()) .delete('/v1/user/') .set('Authorization', `Bearer ${token}`); diff --git a/src/features/user-management/get-all-users/v1/get-all-users.controller.ts b/src/features/user-management/get-all-users/v1/get-all-users.controller.ts index 2b2045e..1f6c518 100644 --- a/src/features/user-management/get-all-users/v1/get-all-users.controller.ts +++ b/src/features/user-management/get-all-users/v1/get-all-users.controller.ts @@ -10,14 +10,14 @@ import { import { Protected } from 'src/shared/decorators/protected.decorator'; import { GetAllUsersOutput, GetAllUsersInput } from './get-all-users.dto'; import { Repository } from 'typeorm'; -import { UserProfile } from 'src/entities/user-profile.entity'; +import { Profile } from 'src/entities/profile.entity'; import { InjectRepository } from '@nestjs/typeorm'; @Controller('/user') export class GetAllUsersController { constructor( - @InjectRepository(UserProfile) - private readonly userProfileRepository: Repository, + @InjectRepository(Profile) + private readonly profileRepository: Repository, ) {} readonly logger = new Logger(this.constructor.name); @@ -28,19 +28,18 @@ export class GetAllUsersController { @Request() req: RequestWithUserData, @Query() { search, quantity, page }: GetAllUsersInput, ): Promise { - const qb = this.userProfileRepository - .createQueryBuilder('userProfile') - .leftJoinAndSelect('userProfile.user', 'user') - .leftJoinAndSelect('userProfile.links', 'links') - .leftJoinAndSelect('userProfile.followers', 'followers') - .leftJoinAndSelect('userProfile.following', 'following') - .where('userProfile.id != :currentUserId', { + const qb = this.profileRepository + .createQueryBuilder('profile') + .leftJoinAndSelect('profile.user', 'user') + .leftJoinAndSelect('profile.followers', 'followers') + .leftJoinAndSelect('profile.following', 'following') + .where('profile.id != :currentUserId', { currentUserId: req.user.userProfileId, }); if (search && search.trim() !== '') { qb.andWhere( - '(user.username ILIKE :search OR userProfile.displayName ILIKE :search)', + '(user.username ILIKE :search OR profile.displayName ILIKE :search)', { search: `%${search}%` }, ); } @@ -50,7 +49,6 @@ export class GetAllUsersController { const [users, total] = await qb.getManyAndCount(); const pages = Math.ceil(total / quantity); const nextPage = page + 1; - return { total, pages, diff --git a/src/features/user-management/get-all-users/v1/get-all-users.dto.ts b/src/features/user-management/get-all-users/v1/get-all-users.dto.ts index 5b46986..98a7a6b 100644 --- a/src/features/user-management/get-all-users/v1/get-all-users.dto.ts +++ b/src/features/user-management/get-all-users/v1/get-all-users.dto.ts @@ -1,6 +1,6 @@ import { Type } from 'class-transformer'; import { IsNumber, IsOptional, IsString, Min } from 'class-validator'; -import { UserProfileDto } from 'src/entities/user-profile.entity'; +import { ProfileDto } from 'src/entities/profile.entity'; export class GetAllUsersInput { @IsOptional() @@ -25,5 +25,5 @@ export class GetAllUsersOutput { nextPage: number | null; pages: number; total: number; - data: UserProfileDto[]; + data: ProfileDto[]; } diff --git a/src/features/user-management/get-all-users/v1/get-all-users.feature.md b/src/features/user-management/get-all-users/v1/get-all-users.feature.md deleted file mode 100644 index e22dc91..0000000 --- a/src/features/user-management/get-all-users/v1/get-all-users.feature.md +++ /dev/null @@ -1,82 +0,0 @@ -# Feature: Get All Users - -## Descrição -Endpoint para listar todos os usuários da plataforma Gamix com paginação e busca. Retorna uma lista paginada de usuários excluindo o usuário atual da requisição. - -## Endpoint -`GET /v1/user?page=0&quantity=10&search=` - -## Query Parameters -- **page** (opcional): Número da página (padrão: 0) -- **quantity** (opcional): Quantidade de itens por página (padrão: 10) -- **search** (opcional): Termo de busca por username ou displayName - -## Response -```json -{ - "page": 0, - "nextPage": 1, - "pages": 5, - "total": 50, - "data": [ - { - "id": "uuid", - "username": "username", - "email": "user@example.com", - "verified": true, - "userProfile": { - "id": "uuid", - "displayName": "Display Name", - "autobiography": "User bio", - "links": [], - "followers": [], - "following": [] - } - } - ] -} -``` - -## Regras de Negócio -- **Autenticação obrigatória** (JWT token) -- **Exclui usuário atual** da lista retornada -- **Paginação** baseada em page e quantity -- **Busca** por username ou displayName (case-insensitive) -- **Relacionamentos** carregados: userProfile, links, followers, following -- **Ordenação** por data de criação (mais recentes primeiro) - -## Validações -- **JWT Token**: Deve ser válido e não expirado -- **Page**: Número inteiro não negativo -- **Quantity**: Número inteiro positivo -- **Search**: String opcional - -## Tratamento de Erros -- **401**: Token JWT inválido ou expirado -- **400**: Parâmetros de query inválidos -- **422**: Validação de campos falhou - -## Segurança -- **Autenticação JWT** obrigatória -- **Proteção** contra acesso não autorizado -- **Exclusão** do usuário atual da lista -- **Validação** de parâmetros de entrada - -## Dependências -- **User Entity**: Para busca de usuários -- **JWT Strategy**: Para validação de token -- **Protected Decorator**: Para controle de acesso -- **TypeORM**: Para operações de banco de dados - -## Paginação -- **Page**: Índice da página (começando em 0) -- **Quantity**: Itens por página -- **Total**: Total de registros -- **Pages**: Total de páginas -- **NextPage**: Próxima página (null se última) - -## Busca -- **Campo**: username ou userProfile.displayName -- **Tipo**: Case-insensitive (ILIKE) -- **Operador**: LIKE com wildcards (%) -- **Comportamento**: Busca parcial em ambos os campos diff --git a/src/features/user-management/get-all-users/v1/get-all-users.spec.ts b/src/features/user-management/get-all-users/v1/get-all-users.spec.ts index 1e960f1..ec66f83 100644 --- a/src/features/user-management/get-all-users/v1/get-all-users.spec.ts +++ b/src/features/user-management/get-all-users/v1/get-all-users.spec.ts @@ -17,7 +17,7 @@ describe('/v1/user (GET)', () => { const token = generateTestJwt({ sub: currentUser.id, - userProfileId: currentUser.userProfileId, + userProfileId: currentUser.profileId, }); const response = await request(app.getHttpServer()) @@ -58,7 +58,7 @@ describe('/v1/user (GET)', () => { const token = generateTestJwt({ sub: currentUser.id, - userProfileId: currentUser.userProfileId, + userProfileId: currentUser.profileId, }); const response = await request(app.getHttpServer()) @@ -79,7 +79,7 @@ describe('/v1/user (GET)', () => { ); const token = generateTestJwt({ sub: currentUser.id, - userProfileId: currentUser.userProfileId, + userProfileId: currentUser.profileId, }); const response = await request(app.getHttpServer()) @@ -107,7 +107,7 @@ describe('/v1/user (GET)', () => { ); const token = generateTestJwt({ sub: currentUser.id, - userProfileId: currentUser.userProfileId, + userProfileId: currentUser.profileId, }); const response = await request(app.getHttpServer()) @@ -136,7 +136,7 @@ describe('/v1/user (GET)', () => { ); const token = generateTestJwt({ sub: currentUser.id, - userProfileId: currentUser.userProfileId, + userProfileId: currentUser.profileId, }); const response = await request(app.getHttpServer()) @@ -157,7 +157,7 @@ describe('/v1/user (GET)', () => { ); const token = generateTestJwt({ sub: currentUser.id, - userProfileId: currentUser.userProfileId, + userProfileId: currentUser.profileId, }); const response = await request(app.getHttpServer()) @@ -177,7 +177,7 @@ describe('/v1/user (GET)', () => { ); const token = generateTestJwt({ sub: currentUser.id, - userProfileId: currentUser.userProfileId, + userProfileId: currentUser.profileId, }); const response = await request(app.getHttpServer()) @@ -196,7 +196,7 @@ describe('/v1/user (GET)', () => { ); const token = generateTestJwt({ sub: currentUser.id, - userProfileId: currentUser.userProfileId, + userProfileId: currentUser.profileId, }); const response = await request(app.getHttpServer()) @@ -214,7 +214,7 @@ describe('/v1/user (GET)', () => { ); const token = generateTestJwt({ sub: currentUser.id, - userProfileId: currentUser.userProfileId, + userProfileId: currentUser.profileId, }); const response = await request(app.getHttpServer()) @@ -233,7 +233,7 @@ describe('/v1/user (GET)', () => { ); const token = generateTestJwt({ sub: currentUser.id, - userProfileId: currentUser.userProfileId, + userProfileId: currentUser.profileId, }); const response = await request(app.getHttpServer()) @@ -259,7 +259,7 @@ describe('/v1/user (GET)', () => { ); const token = generateTestJwt({ sub: currentUser.id, - userProfileId: currentUser.userProfileId, + userProfileId: currentUser.profileId, }); const response = await request(app.getHttpServer()) @@ -269,7 +269,7 @@ describe('/v1/user (GET)', () => { expect(response.status).toEqual(200); const body = response.body as GetAllUsersOutput; expect(body.data).toHaveLength(1); - expect(body.data[0].id).toEqual(otherUser.userProfileId); - expect(body.data[0].id).not.toEqual(currentUser.userProfileId); + expect(body.data[0].id).toEqual(otherUser.profileId); + expect(body.data[0].id).not.toEqual(currentUser.profileId); }); }); diff --git a/src/features/user-management/get-user-by-id/v1/get-user-by-id.controller.ts b/src/features/user-management/get-user-by-id/v1/get-user-by-id.controller.ts index 4d6719c..734dd69 100644 --- a/src/features/user-management/get-user-by-id/v1/get-user-by-id.controller.ts +++ b/src/features/user-management/get-user-by-id/v1/get-user-by-id.controller.ts @@ -11,13 +11,13 @@ import { Repository } from 'typeorm'; import { UserNotFound } from 'src/shared/errors'; import { type RequestWithUserData } from 'src/jwt/jwt.strategy'; import { GetUserByIdInput } from './get-user-by-id.dto'; -import { UserProfile, UserProfileDto } from 'src/entities/user-profile.entity'; +import { Profile, ProfileDto } from 'src/entities/profile.entity'; @Controller('/user') export class GetUserByIdController { constructor( - @InjectRepository(UserProfile) - private readonly userProfileRepository: Repository, + @InjectRepository(Profile) + private readonly profileRepository: Repository, ) {} readonly logger = new Logger(this.constructor.name); @@ -26,12 +26,12 @@ export class GetUserByIdController { async getUserbyId( @Param() { userProfileId }: GetUserByIdInput, @Request() req: RequestWithUserData, - ): Promise { - const user = await this.userProfileRepository.findOne({ + ): Promise { + const profile = await this.profileRepository.findOne({ where: { id: userProfileId }, - relations: ['user', 'links', 'followers', 'following'], + relations: ['user', 'followers', 'following'], }); - if (!user) throw new UserNotFound(); - return user.toDto(req.user?.userProfileId); + if (!profile) throw new UserNotFound(); + return profile.toDto(req.user?.userProfileId); } } diff --git a/src/features/user-management/get-user-by-id/v1/get-user-by-id.feature.md b/src/features/user-management/get-user-by-id/v1/get-user-by-id.feature.md deleted file mode 100644 index 8618d6c..0000000 --- a/src/features/user-management/get-user-by-id/v1/get-user-by-id.feature.md +++ /dev/null @@ -1,90 +0,0 @@ -# Feature: Get User By ID - -## Descrição -Endpoint para buscar um usuário específico pelo ID do perfil. Retorna informações detalhadas do usuário incluindo perfil, links e relacionamentos. - -## Endpoint -`GET /v1/user/{userProfileId}` - -## Path Parameters -- **userProfileId**: ID do perfil do usuário a ser buscado - -## Response -```json -{ - "id": "uuid", - "username": "username", - "email": "user@example.com", - "verified": true, - "userProfile": { - "id": "uuid", - "displayName": "Display Name", - "autobiography": "User bio", - "links": [ - { - "id": "uuid", - "title": "GitHub", - "url": "https://github.com/user", - "type": "social" - } - ], - "followers": [ - { - "id": "uuid", - "followerId": "uuid", - "followingId": "uuid" - } - ], - "following": [ - { - "id": "uuid", - "followerId": "uuid", - "followingId": "uuid" - } - ] - } -} -``` - -## Regras de Negócio -- **Autenticação obrigatória** (JWT token) -- **Busca por userProfileId** (não userId) -- **Relacionamentos** carregados: userProfile, links, followers, following -- **Informações sensíveis** podem ser filtradas baseadas no usuário atual - -## Validações -- **JWT Token**: Deve ser válido e não expirado -- **userProfileId**: Deve ser um UUID válido -- **Usuário**: Deve existir no banco de dados - -## Tratamento de Erros -- **401**: Token JWT inválido ou expirado -- **404**: Usuário não encontrado -- **400**: Parâmetros inválidos -- **422**: Validação de campos falhou - -## Segurança -- **Autenticação JWT** obrigatória -- **Proteção** contra acesso não autorizado -- **Validação** de parâmetros de entrada -- **Filtragem** de informações sensíveis - -## Dependências -- **User Entity**: Para busca do usuário -- **UserProfile Entity**: Para informações do perfil -- **Link Entity**: Para links do usuário -- **Follow Entity**: Para relacionamentos -- **JWT Strategy**: Para validação de token -- **Protected Decorator**: Para controle de acesso - -## Relacionamentos -- **userProfile**: Perfil principal do usuário -- **links**: Links sociais e profissionais -- **followers**: Usuários que seguem este usuário -- **following**: Usuários que este usuário segue - -## Casos de Uso -- **Visualização de perfil** público -- **Busca de usuário** por ID -- **Informações** para sistema de seguidores -- **Links** e informações de contato diff --git a/src/features/user-management/get-user-by-id/v1/get-user-by-id.spec.ts b/src/features/user-management/get-user-by-id/v1/get-user-by-id.spec.ts index 3561afe..3dde15b 100644 --- a/src/features/user-management/get-user-by-id/v1/get-user-by-id.spec.ts +++ b/src/features/user-management/get-user-by-id/v1/get-user-by-id.spec.ts @@ -3,7 +3,7 @@ import { generateTestJwt } from 'test/jwt-helper'; import request from 'supertest'; -import { UserProfileDto } from 'src/entities/user-profile.entity'; +import { ProfileDto } from 'src/entities/profile.entity'; interface ErrorResponse { message: string; @@ -23,16 +23,16 @@ describe('/v1/user/{userProfileId} (GET)', () => { ); const token = generateTestJwt({ sub: currentUser.id, - userProfileId: currentUser.userProfileId, + userProfileId: currentUser.profileId, }); const response = await request(app.getHttpServer()) - .get(`/v1/user/${user.userProfileId}`) + .get(`/v1/user/${user.profileId}`) .set('Authorization', `Bearer ${token}`); expect(response.status).toEqual(200); - const body = response.body as UserProfileDto; - expect(body.id).toEqual(user.userProfileId); + const body = response.body as ProfileDto; + expect(body.id).toEqual(user.profileId); expect(body.user.username).toEqual(user.username); expect(body.user.email).toEqual(user.email); expect(body.user.verified).toEqual(user.verified); @@ -46,7 +46,7 @@ describe('/v1/user/{userProfileId} (GET)', () => { ); const response = await request(app.getHttpServer()).get( - `/v1/user/${user.userProfileId}`, + `/v1/user/${user.profileId}`, ); expect(response.status).toEqual(200); // Guard está funcionando corretamente @@ -60,7 +60,7 @@ describe('/v1/user/{userProfileId} (GET)', () => { ); const response = await request(app.getHttpServer()) - .get(`/v1/user/${user.userProfileId}`) + .get(`/v1/user/${user.profileId}`) .set('Authorization', 'Bearer invalid-token'); expect(response.status).toEqual(401); @@ -74,7 +74,7 @@ describe('/v1/user/{userProfileId} (GET)', () => { ); const token = generateTestJwt({ sub: currentUser.id, - userProfileId: currentUser.userProfileId, + userProfileId: currentUser.profileId, }); const response = await request(app.getHttpServer()) @@ -94,7 +94,7 @@ describe('/v1/user/{userProfileId} (GET)', () => { ); const token = generateTestJwt({ sub: currentUser.id, - userProfileId: currentUser.userProfileId, + userProfileId: currentUser.profileId, }); const response = await request(app.getHttpServer()) @@ -119,15 +119,15 @@ describe('/v1/user/{userProfileId} (GET)', () => { ); const token = generateTestJwt({ sub: currentUser.id, - userProfileId: currentUser.userProfileId, + userProfileId: currentUser.profileId, }); const response = await request(app.getHttpServer()) - .get(`/v1/user/${user.userProfileId}`) + .get(`/v1/user/${user.profileId}`) .set('Authorization', `Bearer ${token}`); expect(response.status).toEqual(200); - const body = response.body as UserProfileDto; + const body = response.body as ProfileDto; expect(body.user).toBeDefined(); expect(body.user.id).toEqual(user.id); }); @@ -145,15 +145,15 @@ describe('/v1/user/{userProfileId} (GET)', () => { ); const token = generateTestJwt({ sub: currentUser.id, - userProfileId: currentUser.userProfileId, + userProfileId: currentUser.profileId, }); const response = await request(app.getHttpServer()) - .get(`/v1/user/${user.userProfileId}`) + .get(`/v1/user/${user.profileId}`) .set('Authorization', `Bearer ${token}`); expect(response.status).toEqual(200); - const body = response.body as UserProfileDto; + const body = response.body as ProfileDto; expect(body.links).toBeDefined(); expect(Array.isArray(body.links)).toBe(true); }); @@ -171,15 +171,15 @@ describe('/v1/user/{userProfileId} (GET)', () => { ); const token = generateTestJwt({ sub: currentUser.id, - userProfileId: currentUser.userProfileId, + userProfileId: currentUser.profileId, }); const response = await request(app.getHttpServer()) - .get(`/v1/user/${user.userProfileId}`) + .get(`/v1/user/${user.profileId}`) .set('Authorization', `Bearer ${token}`); expect(response.status).toEqual(200); - const body = response.body as UserProfileDto; + const body = response.body as ProfileDto; expect(body.followers).toBeDefined(); expect(typeof body.followers).toBe('number'); }); @@ -197,15 +197,15 @@ describe('/v1/user/{userProfileId} (GET)', () => { ); const token = generateTestJwt({ sub: currentUser.id, - userProfileId: currentUser.userProfileId, + userProfileId: currentUser.profileId, }); const response = await request(app.getHttpServer()) - .get(`/v1/user/${user.userProfileId}`) + .get(`/v1/user/${user.profileId}`) .set('Authorization', `Bearer ${token}`); expect(response.status).toEqual(200); - const body = response.body as UserProfileDto; + const body = response.body as ProfileDto; expect(body.following).toBeDefined(); expect(typeof body.following).toBe('number'); }); @@ -223,16 +223,16 @@ describe('/v1/user/{userProfileId} (GET)', () => { ); const token = generateTestJwt({ sub: currentUser.id, - userProfileId: currentUser.userProfileId, + userProfileId: currentUser.profileId, }); const response = await request(app.getHttpServer()) - .get(`/v1/user/${user.userProfileId}`) + .get(`/v1/user/${user.profileId}`) .set('Authorization', `Bearer ${token}`); expect(response.status).toEqual(200); - const body = response.body as UserProfileDto; - expect(body.id).toEqual(user.userProfileId); + const body = response.body as ProfileDto; + expect(body.id).toEqual(user.profileId); }); it('should handle empty userProfileId parameter', async () => { @@ -243,7 +243,7 @@ describe('/v1/user/{userProfileId} (GET)', () => { ); const token = generateTestJwt({ sub: currentUser.id, - userProfileId: currentUser.userProfileId, + userProfileId: currentUser.profileId, }); const response = await request(app.getHttpServer()) diff --git a/src/features/user-management/toggle-follow/v1/toggle-follow.controller.ts b/src/features/user-management/toggle-follow/v1/toggle-follow.controller.ts index 23cef1e..2434424 100644 --- a/src/features/user-management/toggle-follow/v1/toggle-follow.controller.ts +++ b/src/features/user-management/toggle-follow/v1/toggle-follow.controller.ts @@ -1,22 +1,28 @@ import { + Body, Controller, - Post, HttpCode, HttpStatus, Logger, + Post, Request, - Body, Version, } from '@nestjs/common'; -import { Protected } from 'src/shared/decorators/protected.decorator'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { User } from 'src/entities/user.entity'; import { Follow } from 'src/entities/follow.entity'; +import { Project } from 'src/entities/project.entity'; +import { User } from 'src/entities/user.entity'; import { type RequestWithUserData } from 'src/jwt/jwt.strategy'; -import { UserNotFound, YouCannotFollowYourself } from 'src/shared/errors'; +import { Protected } from 'src/shared/decorators/protected.decorator'; +import { + InvalidTarget, + ProjectNotFound, + UserNotFound, + YouCannotFollowYourself, +} from 'src/shared/errors'; +import { safeSave } from 'src/shared/utils/safe-save.util'; +import { Repository } from 'typeorm'; import { ToggleFollowInput } from './toggle-follow.dto'; -import { safeSave } from 'src/shared/utils/safeSave'; @Controller('/follow') export class ToggleFollowController { @@ -25,6 +31,8 @@ export class ToggleFollowController { private readonly followRepository: Repository, @InjectRepository(User) private readonly userRepository: Repository, + @InjectRepository(Project) + private readonly projectRepository: Repository, ) {} readonly logger = new Logger(this.constructor.name); @@ -34,28 +42,42 @@ export class ToggleFollowController { @HttpCode(HttpStatus.NO_CONTENT) async toggleFollow( @Request() { user: { userProfileId } }: RequestWithUserData, - @Body() { targetUserId }: ToggleFollowInput, + @Body() { targetUserId, targetProjectId }: ToggleFollowInput, ): Promise { if (userProfileId === targetUserId) throw new YouCannotFollowYourself(); - const targetUser = await this.userRepository.findOne({ - where: { userProfileId: targetUserId }, - }); - if (!targetUser) throw new UserNotFound(); + else if (targetUserId && targetProjectId) throw new InvalidTarget(); + else if (!targetUserId && !targetProjectId) throw new InvalidTarget(); - const existingFollow = await this.followRepository.findOne({ - where: { + if (targetUserId) { + const targetUser = await this.userRepository.findOne({ + where: { profileId: targetUserId }, + }); + if (!targetUser) throw new UserNotFound(); + } else if (targetProjectId) { + const targetProject = await this.projectRepository.findOne({ + where: { id: targetProjectId }, + }); + if (!targetProject) throw new ProjectNotFound(); + } + + const followData = Object.assign( + { followerUserId: userProfileId, - followingUserId: targetUserId, }, + targetUserId + ? { followingUserId: targetUserId } + : { followingProjectId: targetProjectId }, + ); + + const existingFollow = await this.followRepository.findOne({ + where: followData, }); if (existingFollow) { await this.followRepository.delete(existingFollow.id); return; } - await safeSave(this.followRepository, { - followerUserId: userProfileId, - followingUserId: targetUserId, - }); + await safeSave(this.followRepository, followData); + return; } } diff --git a/src/features/user-management/toggle-follow/v1/toggle-follow.dto.ts b/src/features/user-management/toggle-follow/v1/toggle-follow.dto.ts index b47a068..350bbaa 100644 --- a/src/features/user-management/toggle-follow/v1/toggle-follow.dto.ts +++ b/src/features/user-management/toggle-follow/v1/toggle-follow.dto.ts @@ -1,6 +1,11 @@ -import { IsUUID } from 'class-validator'; +import { IsOptional, IsUUID } from 'class-validator'; export class ToggleFollowInput { + @IsOptional() @IsUUID(undefined, { message: 'INVALID_TARGET_USER_ID' }) targetUserId: string | undefined; + + @IsOptional() + @IsUUID(undefined, { message: 'INVALID_TARGET_PROJECT_ID' }) + targetProjectId: string | undefined; } diff --git a/src/features/user-management/toggle-follow/v1/toggle-follow.feature.md b/src/features/user-management/toggle-follow/v1/toggle-follow.feature.md deleted file mode 100644 index 4211a24..0000000 --- a/src/features/user-management/toggle-follow/v1/toggle-follow.feature.md +++ /dev/null @@ -1,126 +0,0 @@ -# Toggle Follow Feature - -## Visão Geral -Esta feature permite que usuários sigam ou parem de seguir outros usuários. O endpoint funciona como um "toggle" - se o usuário já está seguindo, ele para de seguir; se não está seguindo, ele começa a seguir. - -## Rota - -### POST `/v1/follow` - -**Descrição:** Segue ou para de seguir um usuário específico. - -**Autenticação:** Requerida (Bearer Token) - -**Versão:** v1 - -## Parâmetros - -### Body (JSON) -```json -{ - "targetUserId": "string (UUID)" -} -``` - -| Campo | Tipo | Obrigatório | Descrição | -|-------|------|-------------|-----------| -| `targetUserId` | string (UUID) | Sim | ID do perfil do usuário que será seguido/parado de seguir | - -## Respostas - -### 204 No Content -**Descrição:** Operação realizada com sucesso (seguir ou parar de seguir) - -**Exemplo:** -```http -HTTP/1.1 204 No Content -``` - -### 400 Bad Request -**Descrição:** Erro de validação nos dados enviados - -**Possíveis erros:** -- `targetUserId` não é um UUID válido -- `targetUserId` não foi fornecido - -**Exemplo:** -```json -{ - "statusCode": 400, - "message": "O campo \"userProfileId\" deve ser uma string", - "error": "Bad Request" -} -``` - -### 401 Unauthorized -**Descrição:** Token de autenticação inválido ou ausente - -**Exemplo:** -```json -{ - "statusCode": 401, - "message": "Unauthorized" -} -``` - -### 404 Not Found -**Descrição:** Usuário alvo não encontrado - -**Exemplo:** -```json -{ - "statusCode": 404, - "message": "USER_NOT_FOUND" -} -``` - -### 400 Bad Request - Self Follow -**Descrição:** Tentativa de seguir a si mesmo - -**Exemplo:** -```json -{ - "statusCode": 400, - "message": "YOU_CANNOT_FOLLOW_YOURSELF" -} -``` - -## Comportamento - -1. **Verificação de Autenticação:** O endpoint verifica se o usuário está autenticado -2. **Validação de Dados:** Valida se o `targetUserId` é um UUID válido -3. **Verificação de Auto-seguimento:** Impede que usuários sigam a si mesmos -4. **Verificação de Existência:** Verifica se o usuário alvo existe -5. **Toggle Logic:** - - Se já está seguindo → para de seguir (remove o relacionamento) - - Se não está seguindo → começa a seguir (cria o relacionamento) - -## Exemplos de Uso - -### Seguir um usuário -```bash -curl -X POST http://localhost:3000/v1/follow \ - -H "Authorization: Bearer " \ - -H "Content-Type: application/json" \ - -d '{ - "targetUserId": "123e4567-e89b-12d3-a456-426614174000" - }' -``` - -### Parar de seguir um usuário -```bash -curl -X POST http://localhost:3000/v1/follow \ - -H "Authorization: Bearer " \ - -H "Content-Type: application/json" \ - -d '{ - "targetUserId": "123e4567-e89b-12d3-a456-426614174000" - }' -``` - -## Notas Técnicas - -- O endpoint sempre retorna `204 No Content` em caso de sucesso -- Não há diferença na requisição para seguir ou parar de seguir -- O relacionamento é gerenciado automaticamente baseado no estado atual -- O `userProfileId` do usuário autenticado é extraído do token JWT -- O relacionamento é armazenado na tabela `follow` com `followerUserId` e `followingUserId` diff --git a/src/features/user-management/toggle-follow/v1/toggle-follow.spec.ts b/src/features/user-management/toggle-follow/v1/toggle-follow.spec.ts index 7af448c..8bb1309 100644 --- a/src/features/user-management/toggle-follow/v1/toggle-follow.spec.ts +++ b/src/features/user-management/toggle-follow/v1/toggle-follow.spec.ts @@ -16,14 +16,14 @@ describe('/v1/follow (POST)', () => { ); const token = generateTestJwt({ sub: follower.id, - userProfileId: follower.userProfileId, + userProfileId: follower.profileId, }); const response = await request(app.getHttpServer()) .post('/v1/follow') .set('Authorization', `Bearer ${token}`) .send({ - targetUserId: targetUser.userProfileId, + targetUserId: targetUser.profileId, }); expect(response.status).toEqual(204); @@ -31,8 +31,8 @@ describe('/v1/follow (POST)', () => { // Verify follow relationship was created const follow = await followRepository.findOne({ where: { - followerUserId: follower.userProfileId, - followingUserId: targetUser.userProfileId, + followerUserId: follower.profileId, + followingUserId: targetUser.profileId, }, }); expect(follow).toBeDefined(); @@ -52,20 +52,20 @@ describe('/v1/follow (POST)', () => { // Create existing follow relationship await followRepository.save({ - followerUserId: follower.userProfileId, - followingUserId: targetUser.userProfileId, + followerUserId: follower.profileId, + followingUserId: targetUser.profileId, }); const token = generateTestJwt({ sub: follower.id, - userProfileId: follower.userProfileId, + userProfileId: follower.profileId, }); const response = await request(app.getHttpServer()) .post('/v1/follow') .set('Authorization', `Bearer ${token}`) .send({ - targetUserId: targetUser.userProfileId, + targetUserId: targetUser.profileId, }); expect(response.status).toEqual(204); @@ -73,8 +73,8 @@ describe('/v1/follow (POST)', () => { // Verify follow relationship was removed const follow = await followRepository.findOne({ where: { - followerUserId: follower.userProfileId, - followingUserId: targetUser.userProfileId, + followerUserId: follower.profileId, + followingUserId: targetUser.profileId, }, }); expect(follow).toBeNull(); @@ -88,14 +88,14 @@ describe('/v1/follow (POST)', () => { ); const token = generateTestJwt({ sub: user.id, - userProfileId: user.userProfileId, + userProfileId: user.profileId, }); const response = await request(app.getHttpServer()) .post('/v1/follow') .set('Authorization', `Bearer ${token}`) .send({ - targetUserId: user.userProfileId, // Same as current user + targetUserId: user.profileId, // Same as current user }); expect(response.status).toEqual(400); @@ -110,7 +110,7 @@ describe('/v1/follow (POST)', () => { ); const token = generateTestJwt({ sub: follower.id, - userProfileId: follower.userProfileId, + userProfileId: follower.profileId, }); const response = await request(app.getHttpServer()) @@ -132,7 +132,7 @@ describe('/v1/follow (POST)', () => { ); const token = generateTestJwt({ sub: follower.id, - userProfileId: follower.userProfileId, + userProfileId: follower.profileId, }); const response = await request(app.getHttpServer()) @@ -151,7 +151,7 @@ describe('/v1/follow (POST)', () => { ); const token = generateTestJwt({ sub: follower.id, - userProfileId: follower.userProfileId, + userProfileId: follower.profileId, }); const response = await request(app.getHttpServer()) @@ -198,7 +198,7 @@ describe('/v1/follow (POST)', () => { ); const token = generateTestJwt({ sub: follower.id, - userProfileId: follower.userProfileId, + userProfileId: follower.profileId, }); // First follow @@ -206,15 +206,15 @@ describe('/v1/follow (POST)', () => { .post('/v1/follow') .set('Authorization', `Bearer ${token}`) .send({ - targetUserId: targetUser.userProfileId, + targetUserId: targetUser.profileId, }); expect(response.status).toEqual(204); // Verify follow exists let follow = await followRepository.findOne({ where: { - followerUserId: follower.userProfileId, - followingUserId: targetUser.userProfileId, + followerUserId: follower.profileId, + followingUserId: targetUser.profileId, }, }); expect(follow).toBeDefined(); @@ -224,15 +224,15 @@ describe('/v1/follow (POST)', () => { .post('/v1/follow') .set('Authorization', `Bearer ${token}`) .send({ - targetUserId: targetUser.userProfileId, + targetUserId: targetUser.profileId, }); expect(response.status).toEqual(204); // Verify follow was removed follow = await followRepository.findOne({ where: { - followerUserId: follower.userProfileId, - followingUserId: targetUser.userProfileId, + followerUserId: follower.profileId, + followingUserId: targetUser.profileId, }, }); expect(follow).toBeNull(); @@ -242,15 +242,15 @@ describe('/v1/follow (POST)', () => { .post('/v1/follow') .set('Authorization', `Bearer ${token}`) .send({ - targetUserId: targetUser.userProfileId, + targetUserId: targetUser.profileId, }); expect(response.status).toEqual(204); // Verify follow exists again follow = await followRepository.findOne({ where: { - followerUserId: follower.userProfileId, - followingUserId: targetUser.userProfileId, + followerUserId: follower.profileId, + followingUserId: targetUser.profileId, }, }); expect(follow).toBeDefined(); @@ -275,11 +275,11 @@ describe('/v1/follow (POST)', () => { const token1 = generateTestJwt({ sub: follower1.id, - userProfileId: follower1.userProfileId, + userProfileId: follower1.profileId, }); const token2 = generateTestJwt({ sub: follower2.id, - userProfileId: follower2.userProfileId, + userProfileId: follower2.profileId, }); // First user follows target @@ -287,7 +287,7 @@ describe('/v1/follow (POST)', () => { .post('/v1/follow') .set('Authorization', `Bearer ${token1}`) .send({ - targetUserId: targetUser.userProfileId, + targetUserId: targetUser.profileId, }); expect(response.status).toEqual(204); @@ -296,21 +296,21 @@ describe('/v1/follow (POST)', () => { .post('/v1/follow') .set('Authorization', `Bearer ${token2}`) .send({ - targetUserId: targetUser.userProfileId, + targetUserId: targetUser.profileId, }); expect(response.status).toEqual(204); // Verify both follows exist const follow1 = await followRepository.findOne({ where: { - followerUserId: follower1.userProfileId, - followingUserId: targetUser.userProfileId, + followerUserId: follower1.profileId, + followingUserId: targetUser.profileId, }, }); const follow2 = await followRepository.findOne({ where: { - followerUserId: follower2.userProfileId, - followingUserId: targetUser.userProfileId, + followerUserId: follower2.profileId, + followingUserId: targetUser.profileId, }, }); @@ -338,7 +338,7 @@ describe('/v1/follow (POST)', () => { const token = generateTestJwt({ sub: follower.id, - userProfileId: follower.userProfileId, + userProfileId: follower.profileId, }); // Follow first target @@ -346,7 +346,7 @@ describe('/v1/follow (POST)', () => { .post('/v1/follow') .set('Authorization', `Bearer ${token}`) .send({ - targetUserId: target1.userProfileId, + targetUserId: target1.profileId, }); expect(response.status).toEqual(204); @@ -355,21 +355,21 @@ describe('/v1/follow (POST)', () => { .post('/v1/follow') .set('Authorization', `Bearer ${token}`) .send({ - targetUserId: target2.userProfileId, + targetUserId: target2.profileId, }); expect(response.status).toEqual(204); // Verify both follows exist const follow1 = await followRepository.findOne({ where: { - followerUserId: follower.userProfileId, - followingUserId: target1.userProfileId, + followerUserId: follower.profileId, + followingUserId: target1.profileId, }, }); const follow2 = await followRepository.findOne({ where: { - followerUserId: follower.userProfileId, - followingUserId: target2.userProfileId, + followerUserId: follower.profileId, + followingUserId: target2.profileId, }, }); diff --git a/src/features/user-management/update-user/v1/update-user.controller.ts b/src/features/user-management/update-user/v1/update-user.controller.ts index b303b7a..5e6599c 100644 --- a/src/features/user-management/update-user/v1/update-user.controller.ts +++ b/src/features/user-management/update-user/v1/update-user.controller.ts @@ -14,12 +14,10 @@ import { Protected } from 'src/shared/decorators/protected.decorator'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { type RequestWithUserData } from 'src/jwt/jwt.strategy'; -import { UpdateUserProfileInput } from './update-user.dto'; +import { UpdateProfileInput } from './update-user.dto'; import { FileFieldsInterceptor } from '@nestjs/platform-express'; import { hashSync } from 'bcrypt'; -import { UserProfileDto } from 'src/entities/user-profile.entity'; -import { Link } from 'src/entities/link.entity'; -import { SESGateway } from 'src/shared/gateways/ses.gateway'; +import { ProfileDto } from 'src/entities/profile.entity'; import { S3Gateway, SUPPORTED_IMAGES, @@ -27,16 +25,15 @@ import { } from 'src/shared/gateways/s3.gateway'; import { ConfigService } from '@nestjs/config'; import { PictureUpdateFail, UserNotFound } from 'src/shared/errors'; -import { safeSave } from 'src/shared/utils/safeSave'; -import { UserProfile } from '../../../../entities/user-profile.entity'; +import { safeSave } from 'src/shared/utils/safe-save.util'; +import { Profile } from '../../../../entities/profile.entity'; @Controller('/user') export class UpdateUserController { constructor( - @InjectRepository(UserProfile) - private readonly userProfileRepository: Repository, + @InjectRepository(Profile) + private readonly profileRepository: Repository, private readonly configService: ConfigService, - private readonly sesGateway: SESGateway, private readonly s3Gateway: S3Gateway, ) {} readonly logger = new Logger(this.constructor.name); @@ -56,16 +53,16 @@ export class UpdateUserController { ) async updateUser( @Request() { user: { sub: id, userProfileId } }: RequestWithUserData, - @Body() { autobiography, displayName, links, user }: UpdateUserProfileInput, + @Body() { autobiography, displayName, links, user }: UpdateProfileInput, @UploadedFiles() files: { icon?: Express.Multer.File[]; backgroundImage?: Express.Multer.File[]; }, - ): Promise { - const userProfile = await this.userProfileRepository.findOne({ + ): Promise { + const userProfile = await this.profileRepository.findOne({ where: { id: userProfileId }, - relations: ['links', 'user'], + relations: ['user'], }); if (!userProfile) throw new UserNotFound(); const icon = files.icon?.[0]; @@ -96,16 +93,7 @@ export class UpdateUserController { } if (links) { - await this.userProfileRepository.manager.delete(Link, { - userProfileId, - }); - const updatedLinks = links?.map((link) => { - const updatedLink = new Link(); - updatedLink.url = link; - updatedLink.userProfileId = userProfileId; - return updatedLink; - }); - userProfile.links = updatedLinks; + userProfile.links = links; } Object.assign( @@ -118,14 +106,14 @@ export class UpdateUserController { if (email) { userProfile.user.email = email; userProfile.user.verified = false; - await this.sesGateway.verifyIdentity(email); + // todo: send email verification } Object.assign( userProfile.user, username && { username }, password && { password: hashSync(password, 10) }, ); - await safeSave(this.userProfileRepository, userProfile); + await safeSave(this.profileRepository, userProfile); return userProfile.toDto(); } diff --git a/src/features/user-management/update-user/v1/update-user.dto.ts b/src/features/user-management/update-user/v1/update-user.dto.ts index 3b34800..3fa36f1 100644 --- a/src/features/user-management/update-user/v1/update-user.dto.ts +++ b/src/features/user-management/update-user/v1/update-user.dto.ts @@ -9,8 +9,8 @@ import { MaxLength, validateSync, } from 'class-validator'; -import { InvalidUserProfile } from 'src/shared/errors'; -import { parseArraySafe } from 'src/shared/utils/parseArraySafe'; +import { InvalidProfile } from 'src/shared/errors'; +import { parseArraySafe } from 'src/shared/utils/parse-array-safe.util'; class UpdateUserInput { @IsString({ message: 'INVALID_NAME' }) @@ -30,7 +30,7 @@ class UpdateUserInput { password?: string; } -export class UpdateUserProfileInput { +export class UpdateProfileInput { @IsString({ message: 'INVALID_NAME' }) @Matches(/^[^{}]{3,300}$/, { message: 'INVALID_NAME' }) @IsOptional() @@ -57,10 +57,10 @@ export class UpdateUserProfileInput { const parsed = JSON.parse(value); const instance = plainToInstance(UpdateUserInput, parsed); const errors = validateSync(instance); - if (errors.length) throw new InvalidUserProfile(); + if (errors.length) throw new InvalidProfile(); return instance; } catch (_e) { - throw new InvalidUserProfile(); + throw new InvalidProfile(); } }) @Type(() => UpdateUserInput) diff --git a/src/features/user-management/update-user/v1/update-user.feature.md b/src/features/user-management/update-user/v1/update-user.feature.md deleted file mode 100644 index d46e6c1..0000000 --- a/src/features/user-management/update-user/v1/update-user.feature.md +++ /dev/null @@ -1,202 +0,0 @@ -# Update User Feature - -## Visão Geral -Esta feature permite que usuários autenticados atualizem suas informações pessoais, incluindo dados básicos, perfil e upload de imagens (ícone e imagem de fundo). - -## Rota - -### PATCH `/v1/user` - -**Descrição:** Atualiza informações do usuário autenticado. - -**Autenticação:** Requerida (Bearer Token) - -**Versão:** v1 - -**Content-Type:** `multipart/form-data` - -## Parâmetros - -### Form Data - -#### Campos Básicos do Usuário -| Campo | Tipo | Obrigatório | Descrição | Validação | -|-------|------|-------------|-----------|-----------| -| `username` | string | Não | Nome de usuário | 3-50 caracteres, sem {} | -| `email` | string | Não | Email do usuário | Formato de email válido | -| `password` | string | Não | Nova senha | Mínimo 6 caracteres, deve conter: número, maiúscula, símbolo especial | - -#### Perfil do Usuário (JSON) -| Campo | Tipo | Obrigatório | Descrição | Validação | -|-------|------|-------------|-----------|-----------| -| `userProfile` | JSON string | Não | Dados do perfil | Objeto JSON com campos do perfil | - -**Estrutura do userProfile:** -```json -{ - "displayName": "string", // 3-300 caracteres, sem {} - "autobiography": "string", // 3-500 caracteres, sem {} - "links": ["string"] // Array de URLs válidas (http/https) -} -``` - -#### Upload de Arquivos -| Campo | Tipo | Obrigatório | Descrição | Validação | -|-------|------|-------------|-----------|-----------| -| `icon` | File | Não | Ícone do usuário | JPEG/PNG, máx 15MB | -| `backgroundImage` | File | Não | Imagem de fundo | JPEG/PNG, máx 15MB | - -## Respostas - -### 200 OK -**Descrição:** Usuário atualizado com sucesso - -**Exemplo:** -```json -{ - "id": "123e4567-e89b-12d3-a456-426614174000", - "username": "newusername", - "email": "newemail@example.com", - "verified": false, - "userProfile": { - "id": "456e7890-e89b-12d3-a456-426614174001", - "displayName": "New Display Name", - "autobiography": "This is my biography", - "icon": "https://gamix-public.s3.amazonaws.com/user-123/icon.jpg", - "backgroundImage": "https://gamix-public.s3.amazonaws.com/user-123/background.jpg", - "links": [ - { - "id": "789e0123-e89b-12d3-a456-426614174002", - "url": "https://github.com/user" - } - ], - "followers": 5, - "following": 10 - }, - "userCode": { - "id": "012e3456-e89b-12d3-a456-426614174003", - "code": null - } -} -``` - -### 400 Bad Request -**Descrição:** Erro de validação nos dados enviados - -**Possíveis erros:** -- `INVALID_NAME` - Nome de usuário inválido -- `INVALID_EMAIL` - Email inválido -- `INVALID_PASSWORD` - Senha não atende aos critérios -- `INVALID_AUTOBIOGRAPHY` - Autobiografia inválida -- `INVALID_LINK` - URL inválida nos links -- `LINK_TOO_LONG` - Link muito longo -- `LINKS_MUST_BE_ARRAY` - Links devem ser um array - -**Exemplo:** -```json -{ - "statusCode": 400, - "message": ["INVALID_NAME", "INVALID_EMAIL"], - "error": "Bad Request" -} -``` - -### 401 Unauthorized -**Descrição:** Token de autenticação inválido ou ausente - -**Exemplo:** -```json -{ - "statusCode": 401, - "message": "Unauthorized" -} -``` - -### 500 Internal Server Error -**Descrição:** Erro interno do servidor (ex: falha no upload para S3) - -**Exemplo:** -```json -{ - "statusCode": 500, - "message": "PICTURE_UPDATE_FAIL" -} -``` - -## Comportamento - -### Atualização de Dados Básicos -- **Username:** Atualiza o nome de usuário -- **Email:** Atualiza o email e marca como não verificado (`verified: false`) -- **Password:** Criptografa e atualiza a senha - -### Atualização de Perfil -- **Display Name:** Nome de exibição do usuário -- **Autobiography:** Biografia do usuário -- **Links:** Array de links externos (GitHub, LinkedIn, etc.) - -### Upload de Imagens -- **Icon:** Ícone do usuário (avatar) -- **Background Image:** Imagem de fundo do perfil -- Imagens são enviadas para AWS S3 -- URLs são geradas automaticamente - -### Criação de Perfil -- Se o usuário não tem perfil, um novo é criado -- Se já existe perfil, os dados são atualizados - -## Exemplos de Uso - -### Atualizar dados básicos -```bash -curl -X PATCH http://localhost:3000/v1/user \ - -H "Authorization: Bearer " \ - -F "username=newusername" \ - -F "email=newemail@example.com" -``` - -### Atualizar perfil -```bash -curl -X PATCH http://localhost:3000/v1/user \ - -H "Authorization: Bearer " \ - -F 'userProfile={"displayName":"New Name","autobiography":"My bio","links":["https://github.com/user"]}' -``` - -### Upload de ícone -```bash -curl -X PATCH http://localhost:3000/v1/user \ - -H "Authorization: Bearer " \ - -F "icon=@/path/to/icon.jpg" \ - -F 'userProfile={"displayName":"User Name"}' -``` - -### Upload de imagem de fundo -```bash -curl -X PATCH http://localhost:3000/v1/user \ - -H "Authorization: Bearer " \ - -F "backgroundImage=@/path/to/background.jpg" \ - -F 'userProfile={"displayName":"User Name"}' -``` - -### Atualização completa -```bash -curl -X PATCH http://localhost:3000/v1/user \ - -H "Authorization: Bearer " \ - -F "username=newuser" \ - -F "email=new@example.com" \ - -F "password=NewPass123!" \ - -F "icon=@/path/to/icon.jpg" \ - -F "backgroundImage=@/path/to/background.jpg" \ - -F 'userProfile={"displayName":"Full Name","autobiography":"Complete bio","links":["https://github.com/user","https://linkedin.com/in/user"]}' -``` - -## Notas Técnicas - -- **Content-Type:** Deve ser `multipart/form-data` para suportar upload de arquivos -- **Validação:** Todos os campos são opcionais, mas quando fornecidos devem passar na validação -- **S3 Integration:** Imagens são automaticamente enviadas para AWS S3 -- **Email Verification:** Mudança de email marca o usuário como não verificado -- **Password Hashing:** Senhas são automaticamente criptografadas com bcrypt -- **Profile Creation:** Perfil é criado automaticamente se não existir -- **Links Management:** Links antigos são substituídos pelos novos -- **File Limits:** Máximo 15MB por arquivo, formatos JPEG/PNG diff --git a/src/features/user-management/update-user/v1/update-user.spec.ts b/src/features/user-management/update-user/v1/update-user.spec.ts index cc79e33..40761ab 100644 --- a/src/features/user-management/update-user/v1/update-user.spec.ts +++ b/src/features/user-management/update-user/v1/update-user.spec.ts @@ -12,7 +12,7 @@ describe('/v1/user (PATCH)', () => { ); const token = generateTestJwt({ sub: currentUser.id, - userProfileId: currentUser.userProfileId, + userProfileId: currentUser.profileId, }); const response = await request(app.getHttpServer()) @@ -34,7 +34,7 @@ describe('/v1/user (PATCH)', () => { ); const token = generateTestJwt({ sub: currentUser.id, - userProfileId: currentUser.userProfileId, + userProfileId: currentUser.profileId, }); const response = await request(app.getHttpServer()) @@ -54,7 +54,7 @@ describe('/v1/user (PATCH)', () => { ); const token = generateTestJwt({ sub: currentUser.id, - userProfileId: currentUser.userProfileId, + userProfileId: currentUser.profileId, }); const response = await request(app.getHttpServer()) @@ -80,7 +80,7 @@ describe('/v1/user (PATCH)', () => { ); const token = generateTestJwt({ sub: currentUser.id, - userProfileId: currentUser.userProfileId, + userProfileId: currentUser.profileId, }); const response = await request(app.getHttpServer()) @@ -104,7 +104,7 @@ describe('/v1/user (PATCH)', () => { ); const token = generateTestJwt({ sub: currentUser.id, - userProfileId: currentUser.userProfileId, + userProfileId: currentUser.profileId, }); const response = await request(app.getHttpServer()) @@ -132,7 +132,7 @@ describe('/v1/user (PATCH)', () => { ); const token = generateTestJwt({ sub: currentUser.id, - userProfileId: currentUser.userProfileId, + userProfileId: currentUser.profileId, }); const response = await request(app.getHttpServer()) @@ -164,7 +164,7 @@ describe('/v1/user (PATCH)', () => { ); const token = generateTestJwt({ sub: currentUser.id, - userProfileId: currentUser.userProfileId, + userProfileId: currentUser.profileId, }); const response = await request(app.getHttpServer()) @@ -183,7 +183,7 @@ describe('/v1/user (PATCH)', () => { ); const token = generateTestJwt({ sub: currentUser.id, - userProfileId: currentUser.userProfileId, + userProfileId: currentUser.profileId, }); const response = await request(app.getHttpServer()) @@ -202,7 +202,7 @@ describe('/v1/user (PATCH)', () => { ); const token = generateTestJwt({ sub: currentUser.id, - userProfileId: currentUser.userProfileId, + userProfileId: currentUser.profileId, }); const response = await request(app.getHttpServer()) @@ -221,7 +221,7 @@ describe('/v1/user (PATCH)', () => { ); const token = generateTestJwt({ sub: currentUser.id, - userProfileId: currentUser.userProfileId, + userProfileId: currentUser.profileId, }); const response = await request(app.getHttpServer()) @@ -242,7 +242,7 @@ describe('/v1/user (PATCH)', () => { ); const token = generateTestJwt({ sub: currentUser.id, - userProfileId: currentUser.userProfileId, + userProfileId: currentUser.profileId, }); const response = await request(app.getHttpServer()) @@ -261,7 +261,7 @@ describe('/v1/user (PATCH)', () => { ); const token = generateTestJwt({ sub: currentUser.id, - userProfileId: currentUser.userProfileId, + userProfileId: currentUser.profileId, }); const response = await request(app.getHttpServer()) @@ -280,7 +280,7 @@ describe('/v1/user (PATCH)', () => { ); const token = generateTestJwt({ sub: currentUser.id, - userProfileId: currentUser.userProfileId, + userProfileId: currentUser.profileId, }); jest @@ -323,7 +323,7 @@ describe('/v1/user (PATCH)', () => { ); const token = generateTestJwt({ sub: currentUser.id, - userProfileId: currentUser.userProfileId, + userProfileId: currentUser.profileId, }); const response = await request(app.getHttpServer()) @@ -337,10 +337,9 @@ describe('/v1/user (PATCH)', () => { it('should return 200 and create new user profile when user has no profile', async () => { // Create user without profile - const userCode = await userCodeRepository.save({ code: null }); const bcrypt = require('bcrypt'); const hashedPassword = bcrypt.hashSync('password123', 10); - const userProfile = await userProfileRepository.save({ + const profile = await profileRepository.save({ displayName: 'temp', }); @@ -349,13 +348,12 @@ describe('/v1/user (PATCH)', () => { email: 'test@example.com', password: hashedPassword, verified: true, - userCodeId: userCode.id, - userProfileId: userProfile.id, + profileId: profile.id, }); const token = generateTestJwt({ sub: currentUser.id, - userProfileId: currentUser.userProfileId, + userProfileId: currentUser.profileId, }); const response = await request(app.getHttpServer()) @@ -377,7 +375,7 @@ describe('/v1/user (PATCH)', () => { ); const token = generateTestJwt({ sub: currentUser.id, - userProfileId: currentUser.userProfileId, + userProfileId: currentUser.profileId, }); await request(app.getHttpServer()) diff --git a/src/jwt/jwt.strategy.ts b/src/jwt/jwt.strategy.ts index cdd5c2a..7d03351 100644 --- a/src/jwt/jwt.strategy.ts +++ b/src/jwt/jwt.strategy.ts @@ -16,7 +16,7 @@ export interface Claims { } export interface RequestWithUserData extends Request { - user: Claims; + user: Claims & { user: User }; } @@ -39,6 +39,6 @@ export class JwtStrategy extends PassportStrategy(Strategy) { where: { id: payload.sub } }) if (!user) throw new UserNotAuthorized(); - return payload; + return { ...payload, user }; } } diff --git a/src/setup-application.ts b/src/setup-application.ts index 31dba5f..3dfa3c5 100644 --- a/src/setup-application.ts +++ b/src/setup-application.ts @@ -3,6 +3,7 @@ import { ValidationPipe, VersioningType, } from '@nestjs/common'; +import { GlobalExceptionFilter } from './shared/filters/validation-exception.filter'; export const setupApplication = (app: INestApplication) => { app.enableVersioning({ @@ -16,4 +17,6 @@ export const setupApplication = (app: INestApplication) => { transform: true, }), ); + + app.useGlobalFilters(new GlobalExceptionFilter()); }; diff --git a/src/shared/errors.ts b/src/shared/errors.ts index 01be5c2..0dbc567 100644 --- a/src/shared/errors.ts +++ b/src/shared/errors.ts @@ -1,11 +1,11 @@ import { InternalServiceErrorException } from '@aws-sdk/client-sesv2'; import { + BadRequestException, ConflictException, + ForbiddenException, HttpException, HttpStatus, NotFoundException, - ForbiddenException, - BadRequestException, } from '@nestjs/common'; export class UserNotAuthorized extends ForbiddenException { @@ -38,9 +38,9 @@ export class YouCannotFollowYourself extends BadRequestException { } } -export class InvalidUserProfile extends BadRequestException { +export class InvalidProfile extends BadRequestException { constructor() { - super('INVALID_USER_PROFILE'); + super('INVALID_PROFILE'); } } export class InvalidImageType extends BadRequestException { @@ -84,3 +84,75 @@ export class ThatIsNotYourIdea extends NotFoundException { super('THAT_IS_NOT_YOUR_IDEA'); } } + +export class CommentNotFound extends NotFoundException { + constructor() { + super('COMMENT_NOT_FOUND'); + } +} + +export class CollaborationRequestNotFound extends NotFoundException { + constructor() { + super('COLLABORATION_REQUEST_NOT_FOUND'); + } +} + +export class CollaborationRequestIsNotPending extends BadRequestException { + constructor() { + super('COLLABORATION_REQUEST_IS_NOT_PENDING'); + } +} + +export class PendingCollaborationRequestAlreadyExists extends ConflictException { + constructor() { + super('PENDING_COLLABORATION_REQUEST_ALREADY_EXISTS'); + } +} + +export class YouCannotRequestCollaborationForYourOwnIdea extends BadRequestException { + constructor() { + super('YOU_CANNOT_REQUEST_COLLABORATION_FOR_YOUR_OWN_IDEA'); + } +} + +export class ChatNotFound extends NotFoundException { + constructor() { + super('CHAT_NOT_FOUND'); + } +} + +export class PendingCollaborationRequestNotFound extends NotFoundException { + constructor() { + super('PENDING_COLLABORATION_REQUEST_NOT_FOUND'); + } +} + +export class YouCannotStartChatForAnotherUserIdea extends BadRequestException { + constructor() { + super('YOU_CANNOT_START_CHAT_FOR_ANOTHER_USER_IDEA'); + } +} + +export class YouAreNotTheOwner extends BadRequestException { + constructor() { + super('YOU_ARE_NOT_THE_OWNER'); + } +} + +export class ProjectNotFound extends NotFoundException { + constructor() { + super('PROJECT_NOT_FOUND'); + } +} + +export class InvalidTarget extends BadRequestException { + constructor() { + super('INVALID_TARGET'); + } +} + +export class YouCannotRemoveTheOwner extends BadRequestException { + constructor() { + super('YOU_CANNOT_REMOVE_THE_OWNER'); + } +} diff --git a/src/shared/filters/validation-exception.filter.ts b/src/shared/filters/validation-exception.filter.ts new file mode 100644 index 0000000..3a227b1 --- /dev/null +++ b/src/shared/filters/validation-exception.filter.ts @@ -0,0 +1,45 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, + HttpStatus, +} from '@nestjs/common'; +import { ThrottlerException } from '@nestjs/throttler'; +import { Response } from 'express'; + +@Catch() +export class GlobalExceptionFilter implements ExceptionFilter { + catch(exception: unknown, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + + let status = + exception instanceof HttpException + ? exception.getStatus() + : HttpStatus.INTERNAL_SERVER_ERROR; + + const exceptionResponse = + exception instanceof HttpException ? exception.getResponse() : null; + + let message = + typeof exceptionResponse === 'object' && exceptionResponse !== null + ? (exceptionResponse as { message?: string | string[] }).message || + (exception as HttpException).message + : exception instanceof HttpException + ? exception.message + : 'Internal server error'; + + if (exception instanceof ThrottlerException) { + status = HttpStatus.TOO_MANY_REQUESTS; + message = 'TOO_MANY_REQUESTS'; + } + + const finalMessage = Array.isArray(message) ? message[0] : message; + + response.status(status).json({ + statusCode: status, + message: finalMessage, + }); + } +} diff --git a/src/shared/gateways/chat.gateway.ts b/src/shared/gateways/chat.gateway.ts new file mode 100644 index 0000000..671d8b7 --- /dev/null +++ b/src/shared/gateways/chat.gateway.ts @@ -0,0 +1,260 @@ +import { + WebSocketGateway, + WebSocketServer, + SubscribeMessage, + MessageBody, + ConnectedSocket, + OnGatewayConnection, + OnGatewayDisconnect, +} from '@nestjs/websockets'; +import { ValidationPipe } from '@nestjs/common'; +import { Server, Socket } from 'socket.io'; +import { Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Chat } from 'src/entities/chat.entity'; +import { JwtService } from '@nestjs/jwt'; +import { Claims } from 'src/jwt/jwt.strategy'; +import { IsString, IsUUID } from 'class-validator'; +import { safeSave } from '../utils/safe-save.util'; + +interface AuthenticatedSocket extends Socket { + userProfileId?: string; + currentChatId?: string; +} + +export class JoinChatData { + @IsUUID() + chatId: string; + + @IsString() + token: string; +} + +export class SendMessageData { + @IsString() + content: string; +} + +export interface NewMessageEvent { + chatId: string; + message: { + senderId: string; + content: string; + timestamp: Date; + }; +} + +export enum ChatEvents { + NEW_MESSAGE = 'new_message', + USER_JOINED = 'user_joined', + USER_LEFT = 'user_left', + SEND_MESSAGE = 'send_message', + ERROR = 'error', +} + +@WebSocketGateway({ + cors: { + origin: process.env.FRONTEND_URL!, + }, + namespace: '/chat', +}) +export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { + @WebSocketServer() + server: Server; + + private readonly logger = new Logger(ChatGateway.name); + private userSockets = new Map>(); + + constructor( + @InjectRepository(Chat) + private readonly chatRepository: Repository, + private readonly jwtService: JwtService, + ) {} + + async handleConnection(client: AuthenticatedSocket) { + const { token, chatId } = client.handshake.query; + + try { + const validationPipe = new ValidationPipe({ transform: true }); + const data = (await validationPipe.transform( + { token, chatId }, + { metatype: JoinChatData, type: 'body' }, + )) as JoinChatData; + + if (!data) throw new Error('UNAUTHORIZED'); + + const userProfileId = this.extractUserProfileIdFromToken(data.token); + if (!userProfileId) throw new Error('UNAUTHORIZED'); + + const chat = await this.getChat(data.chatId); + if (!chat) throw new Error('UNAUTHORIZED'); + + const hasAccess = this.verifyChatAccess(chat, userProfileId); + if (!hasAccess) throw new Error('UNAUTHORIZED'); + + client.userProfileId = userProfileId; + client.currentChatId = data.chatId; + + if (!this.userSockets.has(userProfileId)) { + this.userSockets.set(userProfileId, new Set()); + } + this.userSockets.get(userProfileId)!.add(client.id); + + client.join(data.chatId); + this.logger.log(`User ${userProfileId} joined chat ${data.chatId}`); + + this.server.to(data.chatId).emit(ChatEvents.USER_JOINED, { + userProfileId, + chatId: data.chatId, + }); + } catch (error) { + this.logger.error('Error connecting to chat:', error); + client.emit(ChatEvents.ERROR, { + type: 'JOIN_CHAT_ERROR', + message: error.message || 'UNAUTHORIZED', + }); + client.disconnect(); + } + } + + async handleDisconnect(client: AuthenticatedSocket) { + this.logger.log(`Client disconnected: ${client.id}`); + + const userProfileId = client.userProfileId; + const chatId = client.currentChatId; + + if (userProfileId && chatId) { + await client.leave(chatId); + this.logger.log(`User ${userProfileId} left chat ${chatId}`); + + client.to(chatId).emit(ChatEvents.USER_LEFT, { + userProfileId, + chatId: chatId, + }); + } + + if (userProfileId) { + const userSockets = this.userSockets.get(userProfileId); + if (userSockets) { + userSockets.delete(client.id); + if (userSockets.size === 0) { + this.userSockets.delete(userProfileId); + } + } + } + + client.currentChatId = undefined; + } + + @SubscribeMessage(ChatEvents.SEND_MESSAGE) + async handleSendMessage( + @ConnectedSocket() client: AuthenticatedSocket, + @MessageBody(new ValidationPipe({ transform: true })) data: SendMessageData, + ) { + try { + const userProfileId = client.userProfileId; + const chatId = client.currentChatId; + + if (!userProfileId || !chatId) { + client.emit(ChatEvents.ERROR, { + type: 'SEND_MESSAGE_ERROR', + message: 'User not authenticated or not in a chat', + }); + return; + } + + const chat = await this.getChat(chatId); + if (!chat) { + client.emit(ChatEvents.ERROR, { + type: 'SEND_MESSAGE_ERROR', + message: 'Chat not found', + }); + return; + } + + const hasAccess = this.verifyChatAccess(chat, userProfileId); + if (!hasAccess) { + client.emit(ChatEvents.ERROR, { + type: 'SEND_MESSAGE_ERROR', + message: 'Access denied to this chat', + }); + return; + } + + const newMessage = await this.sendMessage( + chat, + userProfileId, + data.content, + ); + + this.broadcastToChat(chatId, ChatEvents.NEW_MESSAGE, { + chatId: chatId, + message: newMessage, + }); + + this.logger.log( + `Message sent in chat ${chatId} by user ${userProfileId}`, + ); + } catch (error) { + this.logger.error('Error sending message:', error); + client.emit(ChatEvents.ERROR, { + type: 'SEND_MESSAGE_ERROR', + message: 'Failed to send message', + }); + } + } + private async getChat(chatId: string): Promise { + return await this.chatRepository.findOne({ + where: { id: chatId }, + relations: ['members'], + }); + } + private verifyChatAccess(chat: Chat, userProfileId: string): boolean { + try { + const isMember = chat.members.some( + (member) => member.id === userProfileId, + ); + + return isMember; + } catch (error) { + this.logger.error('Error verifying chat access:', error); + return false; + } + } + private async sendMessage( + chat: Chat, + userProfileId: string, + content: string, + ): Promise<{ senderId: string; content: string; timestamp: Date }> { + const newMessage = { + senderId: userProfileId, + content: content, + timestamp: new Date(), + }; + + const updatedMessages = [...(chat.messages || []), newMessage]; + + await safeSave(this.chatRepository, { + ...chat, + messages: updatedMessages, + }); + + return newMessage; + } + + private extractUserProfileIdFromToken(token: string): string | null { + const decoded = this.jwtService.verify(token, { + secret: process.env.JWT_SIGNING_KEY_SECRET, + }); + return decoded.userProfileId; + } + + public broadcastToChat( + chatId: string, + event: ChatEvents, + data: NewMessageEvent, + ) { + this.server.to(chatId).emit(event, data); + } +} diff --git a/src/shared/gateways/s3.gateway.ts b/src/shared/gateways/s3.gateway.ts index 425ecf0..fa70873 100644 --- a/src/shared/gateways/s3.gateway.ts +++ b/src/shared/gateways/s3.gateway.ts @@ -29,7 +29,7 @@ type File = { destination: string; filename: string; path: string; - buffer: Buffer; + buffer: Buffer; }; export const fileInterceptorOptions: MulterOptions = { diff --git a/src/shared/gateways/ses.gateway.ts b/src/shared/gateways/ses.gateway.ts index ae1b1a2..f9a2193 100644 --- a/src/shared/gateways/ses.gateway.ts +++ b/src/shared/gateways/ses.gateway.ts @@ -1,6 +1,6 @@ import { SESv2Client, CreateEmailIdentityCommand } from '@aws-sdk/client-sesv2'; import { Inject, Injectable, Logger } from '@nestjs/common'; -import { getErrorMessage } from '../utils/getErrorMessage'; +import { getErrorMessage } from '../utils/get-error-message.util'; export const SES_CLIENT = Symbol('SES_CLIENT'); diff --git a/src/shared/utils/getErrorMessage.ts b/src/shared/utils/get-error-message.util.ts similarity index 100% rename from src/shared/utils/getErrorMessage.ts rename to src/shared/utils/get-error-message.util.ts diff --git a/src/shared/utils/parseArraySafe.ts b/src/shared/utils/parse-array-safe.util.ts similarity index 100% rename from src/shared/utils/parseArraySafe.ts rename to src/shared/utils/parse-array-safe.util.ts diff --git a/src/shared/utils/safeSave.ts b/src/shared/utils/safe-save.util.ts similarity index 100% rename from src/shared/utils/safeSave.ts rename to src/shared/utils/safe-save.util.ts index be449e7..901cc00 100644 --- a/src/shared/utils/safeSave.ts +++ b/src/shared/utils/safe-save.util.ts @@ -1,4 +1,3 @@ -import { DeepPartial, QueryFailedError, Repository } from 'typeorm'; import { BadRequestException, ConflictException, @@ -6,6 +5,7 @@ import { NotFoundException, RequestTimeoutException, } from '@nestjs/common'; +import { DeepPartial, QueryFailedError, Repository } from 'typeorm'; export async function safeSave( repository: Repository, diff --git a/test/module.ts b/test/module.ts index 2aab436..8162e11 100644 --- a/test/module.ts +++ b/test/module.ts @@ -5,6 +5,12 @@ import { setupApplication } from 'src/setup-application'; import { DataSource } from 'typeorm'; import { INestApplication } from '@nestjs/common'; import { S3_CLIENT } from 'src/shared/gateways/s3.gateway'; +import { ConfigService } from '@nestjs/config'; + +jest.mock('@nestjs/throttler', () => ({ + ...jest.requireActual('@nestjs/throttler'), + Throttle: () => () => {}, +})); export const createTestModule = async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ @@ -26,6 +32,14 @@ export const createTestModule = async () => { }), ), }) + .overrideProvider(ConfigService) + .useValue({ + get: (key: string) => { + if (key === 'RATE_LIMIT') return 10000; + if (key === 'RATE_LIMIT_TTL') return 1000; + return process.env[key]; + }, + }) .compile(); return moduleFixture; }; diff --git a/test/test-helpers.ts b/test/test-helpers.ts index 122b737..14b309e 100644 --- a/test/test-helpers.ts +++ b/test/test-helpers.ts @@ -9,11 +9,7 @@ export async function createTestUser( email: string, password: string, ): Promise { - const userCode = await userCodeRepository.save({ - code: null, - }); - - const userProfile = await userProfileRepository.save({ + const profile = await profileRepository.save({ displayName: username, icon: null, autobiography: null, @@ -26,8 +22,7 @@ export async function createTestUser( email, password: hashedPassword, verified: true, - userCodeId: userCode.id, - userProfileId: userProfile.id, + profileId: profile.id, }); return user; diff --git a/test/test-setup.ts b/test/test-setup.ts index 997c98e..6534c16 100644 --- a/test/test-setup.ts +++ b/test/test-setup.ts @@ -1,62 +1,58 @@ -import { INestApplication } from '@nestjs/common'; -import { DataSource, Repository } from 'typeorm'; -import { JwtService } from '@nestjs/jwt'; -import { clearDatabase, createTestModule, setupTestApp } from './module'; -import { User } from 'src/entities/user.entity'; -import { UserCode } from 'src/entities/user-code.entity'; -import { UserProfile } from 'src/entities/user-profile.entity'; -import { App } from 'supertest/types'; import { S3Client } from '@aws-sdk/client-s3'; -import { S3_CLIENT } from '../src/shared/gateways/s3.gateway'; -import { SES_CLIENT } from 'src/shared/gateways/ses.gateway'; import { SESv2Client } from '@aws-sdk/client-sesv2'; +import { INestApplication } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { TestingModule } from '@nestjs/testing'; +import { Comment } from 'src/entities/comment.entity'; import { Follow } from 'src/entities/follow.entity'; import { Idea } from 'src/entities/idea.entity'; -import { Image } from 'src/entities/image.entity'; import { Like } from 'src/entities/like.entity'; -import { Link } from 'src/entities/link.entity'; +import { Profile } from 'src/entities/profile.entity'; import { Tag } from 'src/entities/tag.entity'; +import { User } from 'src/entities/user.entity'; import { View } from 'src/entities/view.entity'; -import { Comment } from 'src/entities/comment.entity'; +import { SES_CLIENT } from 'src/shared/gateways/ses.gateway'; +import { App } from 'supertest/types'; +import { DataSource, Repository } from 'typeorm'; +import { S3_CLIENT } from '../src/shared/gateways/s3.gateway'; +import { clearDatabase, createTestModule, setupTestApp } from './module'; declare global { var app: INestApplication; var dataSource: DataSource; var jwtService: JwtService; var userRepository: Repository; - var userCodeRepository: Repository; - var userProfileRepository: Repository; + var profileRepository: Repository; var followRepository: Repository; var s3Client: S3Client; var sesClient: SESv2Client; var commentRepository: Repository; var ideaRepository: Repository; - var imageRepository: Repository; var likeRepository: Repository; var tagRepository: Repository; - var linkRepository: Repository; var viewRepository: Repository; } -beforeAll(async () => { - const module = await createTestModule(); - const app = await setupTestApp(module); +export const mocksSetup = async (moduleFixture: TestingModule) => { + const app = await setupTestApp(moduleFixture); global.app = app; global.dataSource = app.get(DataSource); global.jwtService = app.get(JwtService); global.userRepository = app.get('UserRepository'); - global.userCodeRepository = app.get('UserCodeRepository'); - global.userProfileRepository = app.get('UserProfileRepository'); + global.profileRepository = app.get('ProfileRepository'); global.commentRepository = app.get('CommentRepository'); global.ideaRepository = app.get('IdeaRepository'); global.followRepository = app.get('FollowRepository'); - global.imageRepository = app.get('ImageRepository'); global.likeRepository = app.get('LikeRepository'); global.tagRepository = app.get('TagRepository'); - global.linkRepository = app.get('LinkRepository'); global.viewRepository = app.get('ViewRepository'); global.s3Client = app.get(S3_CLIENT); global.sesClient = app.get(SES_CLIENT); +}; + +beforeAll(async () => { + const moduleFixture = await createTestModule(); + await mocksSetup(moduleFixture); }); beforeEach(async () => { From 83d989fc708edec29acacb9830029bc149eac1e6 Mon Sep 17 00:00:00 2001 From: gabriel Date: Wed, 5 Nov 2025 21:12:50 -0300 Subject: [PATCH 05/10] fix: Prevent multiple collaborations requests from the same user when it is already approved --- .../v1/collaboration-request.controller.ts | 27 +++++++++++++++++++ src/shared/errors.ts | 12 +++++++++ 2 files changed, 39 insertions(+) diff --git a/src/features/collaboration-requests/collaboration-request/v1/collaboration-request.controller.ts b/src/features/collaboration-requests/collaboration-request/v1/collaboration-request.controller.ts index 29e1f7d..3f05968 100644 --- a/src/features/collaboration-requests/collaboration-request/v1/collaboration-request.controller.ts +++ b/src/features/collaboration-requests/collaboration-request/v1/collaboration-request.controller.ts @@ -23,8 +23,10 @@ import { Profile } from 'src/entities/profile.entity'; import type { RequestWithUserData } from 'src/jwt/jwt.strategy'; import { Protected } from 'src/shared/decorators/protected.decorator'; import { + CollaborationRequestAlreadyApproved, IdeaNotFound, PendingCollaborationRequestAlreadyExists, + UserAlreadyMemberOfTheProject, YouCannotRequestCollaborationForYourOwnIdea, } from 'src/shared/errors'; import { safeSave } from 'src/shared/utils/safe-save.util'; @@ -64,15 +66,40 @@ export class CollaborationRequestController { if (collaborationRequest) { throw new PendingCollaborationRequestAlreadyExists(); } + console.log('aaa'); + + const approvedRequest = await this.collaborationRequestRepository.findOne({ + where: { + requesterId: userProfileId, + ideaId, + status: CollaborationRequestStatus.APPROVED, + } + }); + if (approvedRequest) { + throw new CollaborationRequestAlreadyApproved(); + } + + console.log('bbb'); + const idea = await this.ideaRepository.findOne({ where: { id: ideaId }, + relations: ['project', 'project.members'], }); + if (!idea) throw new IdeaNotFound(); if (idea.authorId === userProfileId) { throw new YouCannotRequestCollaborationForYourOwnIdea(); } + console.log('ccc'); + + if (idea.project && idea.project.members?.some(member => member.id === userProfileId)) { + throw new UserAlreadyMemberOfTheProject(); + } + + console.log('ddd'); + const savedRequest = await safeSave(this.collaborationRequestRepository, { ideaId, message, diff --git a/src/shared/errors.ts b/src/shared/errors.ts index 0dbc567..f5e8c5c 100644 --- a/src/shared/errors.ts +++ b/src/shared/errors.ts @@ -109,6 +109,18 @@ export class PendingCollaborationRequestAlreadyExists extends ConflictException } } +export class CollaborationRequestAlreadyApproved extends BadRequestException { + constructor() { + super('COLLABORATION_REQUEST_ALREADY_APPROVED'); + } +} + +export class UserAlreadyMemberOfTheProject extends BadRequestException { + constructor() { + super('USER_ALREADY_MEMBER_OF_THE_PROJECT'); + } +} + export class YouCannotRequestCollaborationForYourOwnIdea extends BadRequestException { constructor() { super('YOU_CANNOT_REQUEST_COLLABORATION_FOR_YOUR_OWN_IDEA'); From 1b2375fd53428d7ab5d8c595d1f62eb8f164ef51 Mon Sep 17 00:00:00 2001 From: gabriel Date: Wed, 26 Nov 2025 21:15:33 -0300 Subject: [PATCH 06/10] fix: solving requesterName null in get collaboration request --- src/entities/collaboration-request.entity.ts | 12 +++--- .../start-chat/v1/start-chat.controller.ts | 2 + .../v1/collaboration-request.controller.ts | 37 ++++++++----------- 3 files changed, 23 insertions(+), 28 deletions(-) diff --git a/src/entities/collaboration-request.entity.ts b/src/entities/collaboration-request.entity.ts index 20ca2a2..f280366 100644 --- a/src/entities/collaboration-request.entity.ts +++ b/src/entities/collaboration-request.entity.ts @@ -1,15 +1,15 @@ import { - Entity, - PrimaryGeneratedColumn, Column, - ManyToOne, CreateDateColumn, + Entity, JoinColumn, + ManyToOne, OneToOne, + PrimaryGeneratedColumn, } from 'typeorm'; -import { Profile } from './profile.entity'; -import { Idea } from './idea.entity'; import { Chat } from './chat.entity'; +import { Idea } from './idea.entity'; +import { Profile } from './profile.entity'; export enum CollaborationRequestStatus { PENDING = 'Pending', @@ -72,7 +72,7 @@ export class CollaborationRequest { collaborationRequest.message = this.message; collaborationRequest.ideaTitle = this.idea.title; collaborationRequest.requesterName = !isRequester - ? this.requester.displayName + ? this.requester?.displayName ?? null : null; return collaborationRequest; } diff --git a/src/features/chat/start-chat/v1/start-chat.controller.ts b/src/features/chat/start-chat/v1/start-chat.controller.ts index f05eafb..9f749a8 100644 --- a/src/features/chat/start-chat/v1/start-chat.controller.ts +++ b/src/features/chat/start-chat/v1/start-chat.controller.ts @@ -64,6 +64,8 @@ export class StartChatController { if (!idea) throw new IdeaNotFound(); if (idea.authorId !== userProfileId) { + console.log('idea.authorId', idea.authorId); + console.log('userProfileid', userProfileId); throw new YouCannotStartChatForAnotherUserIdea(); } diff --git a/src/features/collaboration-requests/collaboration-request/v1/collaboration-request.controller.ts b/src/features/collaboration-requests/collaboration-request/v1/collaboration-request.controller.ts index 3f05968..e03b5e8 100644 --- a/src/features/collaboration-requests/collaboration-request/v1/collaboration-request.controller.ts +++ b/src/features/collaboration-requests/collaboration-request/v1/collaboration-request.controller.ts @@ -30,7 +30,7 @@ import { YouCannotRequestCollaborationForYourOwnIdea, } from 'src/shared/errors'; import { safeSave } from 'src/shared/utils/safe-save.util'; -import { Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; import { CollaborationRequestInput } from './collaboration-request.dto'; @Controller('/collaboration-request') @@ -55,32 +55,29 @@ export class CollaborationRequestController { @Request() { user: { userProfileId } }: RequestWithUserData, @Body() { message, ideaId }: CollaborationRequestInput, ): Promise { - const collaborationRequest = + const statusesToCheck = [ + CollaborationRequestStatus.PENDING, + CollaborationRequestStatus.APPROVED, + ]; + + const existingRequest = await this.collaborationRequestRepository.findOne({ where: { requesterId: userProfileId, ideaId, - status: CollaborationRequestStatus.PENDING, + status: In(statusesToCheck) }, }); - if (collaborationRequest) { - throw new PendingCollaborationRequestAlreadyExists(); - } - console.log('aaa'); + if (existingRequest) { + if (existingRequest.status === CollaborationRequestStatus.PENDING) { + throw new PendingCollaborationRequestAlreadyExists(); + } - const approvedRequest = await this.collaborationRequestRepository.findOne({ - where: { - requesterId: userProfileId, - ideaId, - status: CollaborationRequestStatus.APPROVED, + if (existingRequest.status === CollaborationRequestStatus.APPROVED) { + throw new CollaborationRequestAlreadyApproved(); } - }); - if (approvedRequest) { - throw new CollaborationRequestAlreadyApproved(); } - - console.log('bbb'); - + const idea = await this.ideaRepository.findOne({ where: { id: ideaId }, relations: ['project', 'project.members'], @@ -92,14 +89,10 @@ export class CollaborationRequestController { throw new YouCannotRequestCollaborationForYourOwnIdea(); } - console.log('ccc'); - if (idea.project && idea.project.members?.some(member => member.id === userProfileId)) { throw new UserAlreadyMemberOfTheProject(); } - console.log('ddd'); - const savedRequest = await safeSave(this.collaborationRequestRepository, { ideaId, message, From f66ecbc27ad6b2c12d0b2bf9538f102242620f52 Mon Sep 17 00:00:00 2001 From: gabriel Date: Sun, 30 Nov 2025 12:42:11 -0300 Subject: [PATCH 07/10] test: test cases for comments --- .vscode/settings.json | 2 +- src/entities/collaboration-request.entity.ts | 2 +- .../v1/collaboration-request.controller.ts | 22 +- .../v1/get-all-comments.controller.ts | 4 +- .../v1/get-all-comments.dto.ts | 2 +- .../v1/get-comment-by-id.spec.ts | 462 ++++++++++++++++++ 6 files changed, 479 insertions(+), 15 deletions(-) create mode 100644 src/features/feed/comments/get-comment-by-id/v1/get-comment-by-id.spec.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 22fc045..7281516 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,7 +6,7 @@ "editor.trimFinalNewlines": false }, "[typescript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.defaultFormatter": "vscode.typescript-language-features", "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit", diff --git a/src/entities/collaboration-request.entity.ts b/src/entities/collaboration-request.entity.ts index f280366..9216c9d 100644 --- a/src/entities/collaboration-request.entity.ts +++ b/src/entities/collaboration-request.entity.ts @@ -72,7 +72,7 @@ export class CollaborationRequest { collaborationRequest.message = this.message; collaborationRequest.ideaTitle = this.idea.title; collaborationRequest.requesterName = !isRequester - ? this.requester?.displayName ?? null + ? (this.requester?.displayName ?? null) : null; return collaborationRequest; } diff --git a/src/features/collaboration-requests/collaboration-request/v1/collaboration-request.controller.ts b/src/features/collaboration-requests/collaboration-request/v1/collaboration-request.controller.ts index e03b5e8..d2b4976 100644 --- a/src/features/collaboration-requests/collaboration-request/v1/collaboration-request.controller.ts +++ b/src/features/collaboration-requests/collaboration-request/v1/collaboration-request.controller.ts @@ -60,14 +60,13 @@ export class CollaborationRequestController { CollaborationRequestStatus.APPROVED, ]; - const existingRequest = - await this.collaborationRequestRepository.findOne({ - where: { - requesterId: userProfileId, - ideaId, - status: In(statusesToCheck) - }, - }); + const existingRequest = await this.collaborationRequestRepository.findOne({ + where: { + requesterId: userProfileId, + ideaId, + status: In(statusesToCheck), + }, + }); if (existingRequest) { if (existingRequest.status === CollaborationRequestStatus.PENDING) { throw new PendingCollaborationRequestAlreadyExists(); @@ -77,7 +76,7 @@ export class CollaborationRequestController { throw new CollaborationRequestAlreadyApproved(); } } - + const idea = await this.ideaRepository.findOne({ where: { id: ideaId }, relations: ['project', 'project.members'], @@ -89,7 +88,10 @@ export class CollaborationRequestController { throw new YouCannotRequestCollaborationForYourOwnIdea(); } - if (idea.project && idea.project.members?.some(member => member.id === userProfileId)) { + if ( + idea.project && + idea.project.members?.some((member) => member.id === userProfileId) + ) { throw new UserAlreadyMemberOfTheProject(); } diff --git a/src/features/feed/comments/get-all-comments/v1/get-all-comments.controller.ts b/src/features/feed/comments/get-all-comments/v1/get-all-comments.controller.ts index 7097732..f28075a 100644 --- a/src/features/feed/comments/get-all-comments/v1/get-all-comments.controller.ts +++ b/src/features/feed/comments/get-all-comments/v1/get-all-comments.controller.ts @@ -39,9 +39,9 @@ export class GetAllCommentsController { if (!ideaId) throw new IdeaNotFound(); const idea = await this.ideaRepository.findOne({ - where: { id: ideaId } + where: { id: ideaId }, }); - + if (!idea) throw new IdeaNotFound(); const qb = this.commentRepository diff --git a/src/features/feed/comments/get-all-comments/v1/get-all-comments.dto.ts b/src/features/feed/comments/get-all-comments/v1/get-all-comments.dto.ts index 73b09b8..d856ae0 100644 --- a/src/features/feed/comments/get-all-comments/v1/get-all-comments.dto.ts +++ b/src/features/feed/comments/get-all-comments/v1/get-all-comments.dto.ts @@ -15,7 +15,7 @@ export class GetAllCommentsInput { @IsOptional() quantity: number = 10; - @IsUUID(undefined, { message: 'INVALID_IDEA_ID'}) + @IsUUID(undefined, { message: 'INVALID_IDEA_ID' }) ideaId: string; @IsOptional() diff --git a/src/features/feed/comments/get-comment-by-id/v1/get-comment-by-id.spec.ts b/src/features/feed/comments/get-comment-by-id/v1/get-comment-by-id.spec.ts new file mode 100644 index 0000000..1110a06 --- /dev/null +++ b/src/features/feed/comments/get-comment-by-id/v1/get-comment-by-id.spec.ts @@ -0,0 +1,462 @@ +import { Comment, CommentDto } from 'src/entities/comment.entity'; +import request from 'supertest'; +import { generateTestJwt } from 'test/jwt-helper'; +import { createTestIdea, createTestUser } from 'test/test-helpers'; +import { DeepPartial } from 'typeorm'; + +interface ErrorResponse { + message: string; +} + +describe('/v1/comment/:id (GET)', () => { + it('sshould return 200 and a comment given the id', async () => { + const user = await createTestUser( + 'testuser', + 'test@example.com', + 'password123', + ); + + const idea = await createTestIdea( + 'title', + 'test content', + user.profileId, + [], + ); + + const tokenUser = generateTestJwt({ + sub: user.id, + userProfileId: user.profileId, + }); + + const createResponse = await request(app.getHttpServer()) + .post('/v1/comment') + .set('Authorization', `Bearer ${tokenUser}`) + .send({ + content: 'Test Comment', + ideaId: idea.id, + }); + + expect(createResponse.status).toEqual(201); + const commentId = createResponse.body.id; + + const response = await request(app.getHttpServer()).get( + `/v1/comment/${commentId}`, + ); + + expect(response.status).toEqual(200); + const body = response.body as CommentDto; + expect(body.id).toBeDefined(); + expect(body.content).toEqual('Test Comment'); + expect(body.author).toBeDefined(); + expect(body.author.id).toEqual(user.profileId); + expect(body.author.displayName).toBeDefined(); + expect(body.ideaId).toEqual(idea.id); + expect(body.parentCommentId).toBeNull(); + expect(body.likes).toBeDefined(); + expect(body.replies).toBeDefined(); + expect(body.isLiked).toBeDefined(); + expect(body.createdAt).toBeDefined(); + }); + + it('should return comment without authentication (public route)', async () => { + const user = await createTestUser( + 'testuser', + 'test@example.com', + 'password123', + ); + + const idea = await createTestIdea( + 'title', + 'test content', + user.profileId, + [], + ); + + const tokenUser = generateTestJwt({ + sub: user.id, + userProfileId: user.profileId, + }); + + const createResponse = await request(app.getHttpServer()) + .post('/v1/comment') + .set('Authorization', `Bearer ${tokenUser}`) + .send({ + content: 'Public Comment', + ideaId: idea.id, + }); + + const commentId = createResponse.body.id; + const response = await request(app.getHttpServer()).get( + `/v1/comment/${commentId}`, + ); + + expect(response.status).toEqual(200); + const body = response.body as CommentDto; + expect(body.content).toEqual('Public Comment'); + }); + + it('should return comment and correct isLiked', async () => { + const user1 = await createTestUser( + 'testuser1', + 'test1@example.com', + 'password123', + ); + + const user2 = await createTestUser( + 'testuser2', + 'test2@example.com', + 'password123', + ); + + const user3 = await createTestUser( + 'testuser3', + 'test3@example.com', + 'password123', + ); + + const idea = await createTestIdea( + 'title', + 'test content', + user1.profileId, + [], + ); + + const tokenUser1 = generateTestJwt({ + sub: user1.id, + userProfileId: user1.profileId, + }); + + const createResponse = await request(app.getHttpServer()) + .post('/v1/comment') + .set('Authorization', `Bearer ${tokenUser1}`) + .send({ + content: 'Comment with likes', + ideaId: idea.id, + }); + + const commentId = createResponse.body.id; + await likeRepository.save({ + profileId: user2.profileId, + commentId: commentId, + ideaId: null, + }); + + const tokenUser2 = generateTestJwt({ + sub: user2.id, + userProfileId: user2.profileId, + }); + + // GET with user 2 + const responseUser2 = await request(app.getHttpServer()) + .get(`/v1/comment/${commentId}`) + .set('Authorization', `Bearer ${tokenUser2}`); + + expect(responseUser2.status).toEqual(200); + const bodyUser2 = responseUser2.body as CommentDto; + expect(bodyUser2.isLiked).toBe(true); // user2 liked + + const tokenUser3 = generateTestJwt({ + sub: user3.id, + userProfileId: user3.profileId, + }); + + // GET user 3 + const responseUser3 = await request(app.getHttpServer()) + .get(`/v1/comment/${commentId}`) + .set('Authorization', `Bearer ${tokenUser3}`); + + expect(responseUser3.status).toEqual(200); + const bodyUser3 = responseUser3.body as CommentDto; + expect(bodyUser3.isLiked).toBe(false); // user3 did not liked + }); + + it('should return comment with replies', async () => { + const user = await createTestUser( + 'testuser', + 'test@example.com', + 'password123', + ); + + const idea = await createTestIdea( + 'title', + 'test content', + user.profileId, + [], + ); + + const tokenUser = generateTestJwt({ + sub: user.id, + userProfileId: user.profileId, + }); + + const parentCommentResponse = await request(app.getHttpServer()) + .post('/v1/comment') + .set('Authorization', `Bearer ${tokenUser}`) + .send({ + content: 'Parent Comment', + ideaId: idea.id, + }); + + const parentCommentId = parentCommentResponse.body.id; + + await commentRepository.save({ + content: 'Reply 1', + authorId: user.profileId, + ideaId: idea.id, + parentCommentId: parentCommentId, + } as DeepPartial); + + await commentRepository.save({ + content: 'Reply 2', + authorId: user.profileId, + ideaId: idea.id, + parentCommentId: parentCommentId, + } as DeepPartial); + + await commentRepository.save({ + content: 'Reply 3', + authorId: user.profileId, + ideaId: idea.id, + parentCommentId: parentCommentId, + } as DeepPartial); + + const response = await request(app.getHttpServer()).get( + `/v1/comment/${parentCommentId}`, + ); + + expect(response.status).toEqual(200); + const body = response.body as CommentDto; + expect(body.replies).toEqual(3); // 3 replies + }); + + it('should return comment with likes', async () => { + const user1 = await createTestUser( + 'testuser1', + 'test1@example.com', + 'password123', + ); + + const user2 = await createTestUser( + 'testuser2', + 'test2@example.com', + 'password123', + ); + + const user3 = await createTestUser( + 'testuser3', + 'test3@example.com', + 'password123', + ); + + const user4 = await createTestUser( + 'testuser4', + 'test4@example.com', + 'password123', + ); + + const idea = await createTestIdea( + 'title', + 'test content', + user1.profileId, + [], + ); + + const tokenUser1 = generateTestJwt({ + sub: user1.id, + userProfileId: user1.profileId, + }); + + const createResponse = await request(app.getHttpServer()) + .post('/v1/comment') + .set('Authorization', `Bearer ${tokenUser1}`) + .send({ + content: 'Comment with multiple likes', + ideaId: idea.id, + }); + + const commentId = createResponse.body.id; + + + await likeRepository.save({ + profileId: user2.profileId, + commentId: commentId, + ideaId: null, + }); + + await likeRepository.save({ + profileId: user3.profileId, + commentId: commentId, + ideaId: null, + }); + + await likeRepository.save({ + profileId: user4.profileId, + commentId: commentId, + ideaId: null, + }); + + const response = await request(app.getHttpServer()).get( + `/v1/comment/${commentId}`, + ); + + expect(response.status).toEqual(200); + const body = response.body as CommentDto; + expect(body.likes).toEqual(3); // 3 likes + }); + + it('should return comment without replies and likes', async () => { + const user = await createTestUser( + 'testuser', + 'test@example.com', + 'password123', + ); + + const idea = await createTestIdea( + 'title', + 'test content', + user.profileId, + [], + ); + + const tokenUser = generateTestJwt({ + sub: user.id, + userProfileId: user.profileId, + }); + + const createResponse = await request(app.getHttpServer()) + .post('/v1/comment') + .set('Authorization', `Bearer ${tokenUser}`) + .send({ + content: 'Comment without replies and likes', + ideaId: idea.id, + }); + + const commentId = createResponse.body.id; + + const response = await request(app.getHttpServer()).get( + `/v1/comment/${commentId}`, + ); + + expect(response.status).toEqual(200); + const body = response.body as CommentDto; + expect(body.replies).toEqual(0); + expect(body.likes).toEqual(0); + expect(body.isLiked).toBe(false); + }); + + it('should return reply comment (with parentCommentId)', async () => { + const user = await createTestUser( + 'testuser', + 'test@example.com', + 'password123', + ); + + const idea = await createTestIdea( + 'title', + 'test content', + user.profileId, + [], + ); + + const tokenUser = generateTestJwt({ + sub: user.id, + userProfileId: user.profileId, + }); + + const parentCommentResponse = await request(app.getHttpServer()) + .post('/v1/comment') + .set('Authorization', `Bearer ${tokenUser}`) + .send({ + content: 'Parent Comment', + ideaId: idea.id, + }); + + const parentCommentId = parentCommentResponse.body.id; + + const replyResponse = await request(app.getHttpServer()) + .post('/v1/comment') + .set('Authorization', `Bearer ${tokenUser}`) + .send({ + content: 'Reply Comment', + ideaId: idea.id, + parentCommentId: parentCommentId, + }); + + const replyId = replyResponse.body.id; + + const response = await request(app.getHttpServer()).get( + `/v1/comment/${replyId}`, + ); + + expect(response.status).toEqual(200); + const body = response.body as CommentDto; + expect(body.content).toEqual('Reply Comment'); + expect(body.parentCommentId).toEqual(parentCommentId); + }); + + it('should return 400 when ID is invalid (not UUID)', async () => { + const response = await request(app.getHttpServer()).get( + '/v1/comment/invalid-id', + ); + + expect(response.status).toBe(400); + const body = response.body as ErrorResponse; + expect(body.message).toContain('INVALID_ID'); + }); + + it('should return 404 when comment does not exist', async () => { + const nonExistentId = '1e9c20a4-3f8d-4b71-8c0e-92a15f0b6d2c'; + const response = await request(app.getHttpServer()).get( + `/v1/comment/${nonExistentId}`, + ); + + expect(response.status).toBe(404); + const body = response.body as ErrorResponse; + expect(body.message).toContain('COMMENT_NOT_FOUND'); + }); + + it('should load author with user correctly', async () => { + const user = await createTestUser( + 'testuser', + 'test@example.com', + 'password123', + ); + + const idea = await createTestIdea( + 'Test Idea Title', + 'This is the content of the test idea', + user.profileId, + [], + ); + + const tokenUser = generateTestJwt({ + sub: user.id, + userProfileId: user.profileId, + }); + + const createResponse = await request(app.getHttpServer()) + .post('/v1/comment') + .set('Authorization', `Bearer ${tokenUser}`) + .send({ + content: 'Comment to test author', + ideaId: idea.id, + }); + + const commentId = createResponse.body.id; + + const response = await request(app.getHttpServer()).get( + `/v1/comment/${commentId}`, + ); + + expect(response.status).toEqual(200); + const body = response.body as CommentDto; + expect(body.author).toBeDefined(); + expect(body.author.id).toBeDefined(); + expect(body.author.id).toEqual(user.profileId); + expect(body.author.displayName).toBeDefined(); + expect(body.author.displayName).toEqual('testuser'); + expect(body.author.user).toBeDefined(); + expect(body.author.user.username).toEqual('testuser'); + expect(body.author.user.email).toEqual('test@example.com'); + }); +}); From 34cf3eab9ec89208c57a765810bc652e41082e7a Mon Sep 17 00:00:00 2001 From: gabriel Date: Sun, 30 Nov 2025 13:40:19 -0300 Subject: [PATCH 08/10] fix: add missing dependecies on package-lock.json --- package-lock.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/package-lock.json b/package-lock.json index 9fe8e20..25625d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4919,6 +4919,18 @@ "node": ">=18.0.0" } }, + "node_modules/@smithy/uuid": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz", + "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", From 5bd4509eee8298e8668a8a9f82329ba8238f5abd Mon Sep 17 00:00:00 2001 From: gabriel Date: Sun, 30 Nov 2025 20:13:02 -0300 Subject: [PATCH 09/10] refactor: removing repeated code lines --- .../v1/get-comment-by-id.spec.ts | 734 +++++++----------- 1 file changed, 295 insertions(+), 439 deletions(-) diff --git a/src/features/feed/comments/get-comment-by-id/v1/get-comment-by-id.spec.ts b/src/features/feed/comments/get-comment-by-id/v1/get-comment-by-id.spec.ts index 1110a06..bb53762 100644 --- a/src/features/feed/comments/get-comment-by-id/v1/get-comment-by-id.spec.ts +++ b/src/features/feed/comments/get-comment-by-id/v1/get-comment-by-id.spec.ts @@ -1,462 +1,318 @@ import { Comment, CommentDto } from 'src/entities/comment.entity'; +import { Idea } from 'src/entities/idea.entity'; +import { User } from 'src/entities/user.entity'; import request from 'supertest'; import { generateTestJwt } from 'test/jwt-helper'; import { createTestIdea, createTestUser } from 'test/test-helpers'; import { DeepPartial } from 'typeorm'; interface ErrorResponse { - message: string; + message: string; } -describe('/v1/comment/:id (GET)', () => { - it('sshould return 200 and a comment given the id', async () => { - const user = await createTestUser( - 'testuser', - 'test@example.com', - 'password123', - ); - - const idea = await createTestIdea( - 'title', - 'test content', - user.profileId, - [], - ); - - const tokenUser = generateTestJwt({ - sub: user.id, - userProfileId: user.profileId, - }); - - const createResponse = await request(app.getHttpServer()) - .post('/v1/comment') - .set('Authorization', `Bearer ${tokenUser}`) - .send({ - content: 'Test Comment', - ideaId: idea.id, - }); - - expect(createResponse.status).toEqual(201); - const commentId = createResponse.body.id; - - const response = await request(app.getHttpServer()).get( - `/v1/comment/${commentId}`, - ); - - expect(response.status).toEqual(200); - const body = response.body as CommentDto; - expect(body.id).toBeDefined(); - expect(body.content).toEqual('Test Comment'); - expect(body.author).toBeDefined(); - expect(body.author.id).toEqual(user.profileId); - expect(body.author.displayName).toBeDefined(); - expect(body.ideaId).toEqual(idea.id); - expect(body.parentCommentId).toBeNull(); - expect(body.likes).toBeDefined(); - expect(body.replies).toBeDefined(); - expect(body.isLiked).toBeDefined(); - expect(body.createdAt).toBeDefined(); - }); +interface TestSetup { + user: User; + idea: Idea; + token: string; +} - it('should return comment without authentication (public route)', async () => { - const user = await createTestUser( - 'testuser', - 'test@example.com', - 'password123', - ); - - const idea = await createTestIdea( - 'title', - 'test content', - user.profileId, - [], - ); - - const tokenUser = generateTestJwt({ - sub: user.id, - userProfileId: user.profileId, - }); - - const createResponse = await request(app.getHttpServer()) - .post('/v1/comment') - .set('Authorization', `Bearer ${tokenUser}`) - .send({ - content: 'Public Comment', - ideaId: idea.id, - }); - - const commentId = createResponse.body.id; - const response = await request(app.getHttpServer()).get( - `/v1/comment/${commentId}`, - ); - - expect(response.status).toEqual(200); - const body = response.body as CommentDto; - expect(body.content).toEqual('Public Comment'); - }); +interface UserWithToken { + user: User; + token: string; +} - it('should return comment and correct isLiked', async () => { - const user1 = await createTestUser( - 'testuser1', - 'test1@example.com', - 'password123', - ); - - const user2 = await createTestUser( - 'testuser2', - 'test2@example.com', - 'password123', - ); - - const user3 = await createTestUser( - 'testuser3', - 'test3@example.com', - 'password123', - ); - - const idea = await createTestIdea( - 'title', - 'test content', - user1.profileId, - [], - ); - - const tokenUser1 = generateTestJwt({ - sub: user1.id, - userProfileId: user1.profileId, - }); - - const createResponse = await request(app.getHttpServer()) - .post('/v1/comment') - .set('Authorization', `Bearer ${tokenUser1}`) - .send({ - content: 'Comment with likes', - ideaId: idea.id, - }); - - const commentId = createResponse.body.id; - await likeRepository.save({ - profileId: user2.profileId, - commentId: commentId, - ideaId: null, - }); - - const tokenUser2 = generateTestJwt({ - sub: user2.id, - userProfileId: user2.profileId, - }); - - // GET with user 2 - const responseUser2 = await request(app.getHttpServer()) - .get(`/v1/comment/${commentId}`) - .set('Authorization', `Bearer ${tokenUser2}`); - - expect(responseUser2.status).toEqual(200); - const bodyUser2 = responseUser2.body as CommentDto; - expect(bodyUser2.isLiked).toBe(true); // user2 liked - - const tokenUser3 = generateTestJwt({ - sub: user3.id, - userProfileId: user3.profileId, - }); - - // GET user 3 - const responseUser3 = await request(app.getHttpServer()) - .get(`/v1/comment/${commentId}`) - .set('Authorization', `Bearer ${tokenUser3}`); - - expect(responseUser3.status).toEqual(200); - const bodyUser3 = responseUser3.body as CommentDto; - expect(bodyUser3.isLiked).toBe(false); // user3 did not liked - }); +async function setupTestUserAndIdea( + username = 'testuser', + email = 'test@example.com', + ideaTitle = 'title', + ideaContent = 'test content', +): Promise { + const user = await createTestUser(username, email, 'password123'); + const idea = await createTestIdea(ideaTitle, ideaContent, user.profileId, []); + const token = generateTestJwt({ + sub: user.id, + userProfileId: user.profileId, + }); + return { user, idea, token }; +} - it('should return comment with replies', async () => { - const user = await createTestUser( - 'testuser', - 'test@example.com', - 'password123', - ); - - const idea = await createTestIdea( - 'title', - 'test content', - user.profileId, - [], - ); - - const tokenUser = generateTestJwt({ - sub: user.id, - userProfileId: user.profileId, - }); - - const parentCommentResponse = await request(app.getHttpServer()) - .post('/v1/comment') - .set('Authorization', `Bearer ${tokenUser}`) - .send({ - content: 'Parent Comment', - ideaId: idea.id, - }); - - const parentCommentId = parentCommentResponse.body.id; - - await commentRepository.save({ - content: 'Reply 1', - authorId: user.profileId, - ideaId: idea.id, - parentCommentId: parentCommentId, - } as DeepPartial); - - await commentRepository.save({ - content: 'Reply 2', - authorId: user.profileId, - ideaId: idea.id, - parentCommentId: parentCommentId, - } as DeepPartial); - - await commentRepository.save({ - content: 'Reply 3', - authorId: user.profileId, - ideaId: idea.id, - parentCommentId: parentCommentId, - } as DeepPartial); - - const response = await request(app.getHttpServer()).get( - `/v1/comment/${parentCommentId}`, - ); - - expect(response.status).toEqual(200); - const body = response.body as CommentDto; - expect(body.replies).toEqual(3); // 3 replies - }); +async function setupTestUserWithToken( + username: string, + email: string, +): Promise { + const user = await createTestUser(username, email, 'password123'); + const token = generateTestJwt({ + sub: user.id, + userProfileId: user.profileId, + }); + return { user, token }; +} - it('should return comment with likes', async () => { - const user1 = await createTestUser( - 'testuser1', - 'test1@example.com', - 'password123', - ); - - const user2 = await createTestUser( - 'testuser2', - 'test2@example.com', - 'password123', - ); - - const user3 = await createTestUser( - 'testuser3', - 'test3@example.com', - 'password123', - ); - - const user4 = await createTestUser( - 'testuser4', - 'test4@example.com', - 'password123', - ); - - const idea = await createTestIdea( - 'title', - 'test content', - user1.profileId, - [], - ); - - const tokenUser1 = generateTestJwt({ - sub: user1.id, - userProfileId: user1.profileId, - }); - - const createResponse = await request(app.getHttpServer()) - .post('/v1/comment') - .set('Authorization', `Bearer ${tokenUser1}`) - .send({ - content: 'Comment with multiple likes', - ideaId: idea.id, - }); - - const commentId = createResponse.body.id; - - - await likeRepository.save({ - profileId: user2.profileId, - commentId: commentId, - ideaId: null, - }); - - await likeRepository.save({ - profileId: user3.profileId, - commentId: commentId, - ideaId: null, - }); - - await likeRepository.save({ - profileId: user4.profileId, - commentId: commentId, - ideaId: null, - }); - - const response = await request(app.getHttpServer()).get( - `/v1/comment/${commentId}`, - ); - - expect(response.status).toEqual(200); - const body = response.body as CommentDto; - expect(body.likes).toEqual(3); // 3 likes - }); +async function setupMultipleTestUsers( + count: number, + startIndex = 100, +): Promise { + const users: UserWithToken[] = []; + const timestamp = Date.now(); + for (let i = 0; i < count; i++) { + const index = startIndex + i; + const userWithToken = await setupTestUserWithToken( + `testuser${index}`, + `test${index}-${timestamp}@example.com`, + ); + users.push(userWithToken); + } + return users; +} - it('should return comment without replies and likes', async () => { - const user = await createTestUser( - 'testuser', - 'test@example.com', - 'password123', - ); - - const idea = await createTestIdea( - 'title', - 'test content', - user.profileId, - [], - ); - - const tokenUser = generateTestJwt({ - sub: user.id, - userProfileId: user.profileId, - }); - - const createResponse = await request(app.getHttpServer()) - .post('/v1/comment') - .set('Authorization', `Bearer ${tokenUser}`) - .send({ - content: 'Comment without replies and likes', - ideaId: idea.id, - }); - - const commentId = createResponse.body.id; - - const response = await request(app.getHttpServer()).get( - `/v1/comment/${commentId}`, - ); - - expect(response.status).toEqual(200); - const body = response.body as CommentDto; - expect(body.replies).toEqual(0); - expect(body.likes).toEqual(0); - expect(body.isLiked).toBe(false); +async function createTestComment( + content: string, + ideaId: string, + token: string, + parentCommentId?: string, +): Promise { + const response = await request(app.getHttpServer()) + .post('/v1/comment') + .set('Authorization', `Bearer ${token}`) + .send({ + content, + ideaId, + ...(parentCommentId && { parentCommentId }), }); - it('should return reply comment (with parentCommentId)', async () => { - const user = await createTestUser( - 'testuser', - 'test@example.com', - 'password123', - ); - - const idea = await createTestIdea( - 'title', - 'test content', - user.profileId, - [], - ); - - const tokenUser = generateTestJwt({ - sub: user.id, - userProfileId: user.profileId, - }); - - const parentCommentResponse = await request(app.getHttpServer()) - .post('/v1/comment') - .set('Authorization', `Bearer ${tokenUser}`) - .send({ - content: 'Parent Comment', - ideaId: idea.id, - }); - - const parentCommentId = parentCommentResponse.body.id; - - const replyResponse = await request(app.getHttpServer()) - .post('/v1/comment') - .set('Authorization', `Bearer ${tokenUser}`) - .send({ - content: 'Reply Comment', - ideaId: idea.id, - parentCommentId: parentCommentId, - }); - - const replyId = replyResponse.body.id; - - const response = await request(app.getHttpServer()).get( - `/v1/comment/${replyId}`, - ); - - expect(response.status).toEqual(200); - const body = response.body as CommentDto; - expect(body.content).toEqual('Reply Comment'); - expect(body.parentCommentId).toEqual(parentCommentId); - }); + expect(response.status).toEqual(201); + return response.body.id; +} - it('should return 400 when ID is invalid (not UUID)', async () => { - const response = await request(app.getHttpServer()).get( - '/v1/comment/invalid-id', - ); +async function getCommentById( + commentId: string, + token?: string, +): Promise { + const req = request(app.getHttpServer()).get(`/v1/comment/${commentId}`); + if (token) { + req.set('Authorization', `Bearer ${token}`); + } + return req; +} - expect(response.status).toBe(400); - const body = response.body as ErrorResponse; - expect(body.message).toContain('INVALID_ID'); +async function createLikesForComment( + commentId: string, + userIds: string[], +): Promise { + for (const userId of userIds) { + await likeRepository.save({ + profileId: userId, + commentId: commentId, + ideaId: null, }); + } +} - it('should return 404 when comment does not exist', async () => { - const nonExistentId = '1e9c20a4-3f8d-4b71-8c0e-92a15f0b6d2c'; - const response = await request(app.getHttpServer()).get( - `/v1/comment/${nonExistentId}`, - ); +async function createRepliesForComment( + parentCommentId: string, + ideaId: string, + authorId: string, + count: number, +): Promise { + for (let i = 1; i <= count; i++) { + await commentRepository.save({ + content: `Reply ${i}`, + authorId: authorId, + ideaId: ideaId, + parentCommentId: parentCommentId, + } as DeepPartial); + } +} - expect(response.status).toBe(404); - const body = response.body as ErrorResponse; - expect(body.message).toContain('COMMENT_NOT_FOUND'); - }); +function expectCommentStructure(body: CommentDto): void { + expect(body.id).toBeDefined(); + expect(body.content).toBeDefined(); + expect(body.author).toBeDefined(); + expect(body.ideaId).toBeDefined(); + expect(body.parentCommentId).toBeDefined(); + expect(body.likes).toBeDefined(); + expect(body.replies).toBeDefined(); + expect(body.isLiked).toBeDefined(); + expect(body.createdAt).toBeDefined(); +} - it('should load author with user correctly', async () => { - const user = await createTestUser( - 'testuser', - 'test@example.com', - 'password123', - ); - - const idea = await createTestIdea( - 'Test Idea Title', - 'This is the content of the test idea', - user.profileId, - [], - ); - - const tokenUser = generateTestJwt({ - sub: user.id, - userProfileId: user.profileId, - }); - - const createResponse = await request(app.getHttpServer()) - .post('/v1/comment') - .set('Authorization', `Bearer ${tokenUser}`) - .send({ - content: 'Comment to test author', - ideaId: idea.id, - }); - - const commentId = createResponse.body.id; - - const response = await request(app.getHttpServer()).get( - `/v1/comment/${commentId}`, - ); - - expect(response.status).toEqual(200); - const body = response.body as CommentDto; - expect(body.author).toBeDefined(); - expect(body.author.id).toBeDefined(); - expect(body.author.id).toEqual(user.profileId); - expect(body.author.displayName).toBeDefined(); - expect(body.author.displayName).toEqual('testuser'); - expect(body.author.user).toBeDefined(); - expect(body.author.user.username).toEqual('testuser'); - expect(body.author.user.email).toEqual('test@example.com'); - }); +describe('/v1/comment/:id (GET)', () => { + it('should return 200 and a comment given the id', async () => { + const { user, idea, token } = await setupTestUserAndIdea(); + const commentId = await createTestComment('Test Comment', idea.id, token); + + const response = await getCommentById(commentId); + + expect(response.status).toEqual(200); + const body = response.body as CommentDto; + expectCommentStructure(body); + expect(body.content).toEqual('Test Comment'); + expect(body.author.id).toEqual(user.profileId); + expect(body.author.displayName).toBeDefined(); + expect(body.ideaId).toEqual(idea.id); + expect(body.parentCommentId).toBeNull(); + }); + + it('should return comment without authentication (public route)', async () => { + const { idea, token } = await setupTestUserAndIdea(); + const commentId = await createTestComment('Public Comment', idea.id, token); + + const response = await getCommentById(commentId); + + expect(response.status).toEqual(200); + const body = response.body as CommentDto; + expect(body.content).toEqual('Public Comment'); + expect(body.isLiked).toBe(false); + }); + + it('should return comment and correct isLiked', async () => { + const { user: user1, idea, token } = await setupTestUserAndIdea( + 'testuser1', + 'test1@example.com', + ); + const [user2, user3] = await setupMultipleTestUsers(2); + const commentId = await createTestComment( + 'Comment with likes', + idea.id, + token, + ); + + await createLikesForComment(commentId, [user2.user.profileId]); + + const responseUser2 = await getCommentById(commentId, user2.token); + expect(responseUser2.status).toEqual(200); + expect(responseUser2.body.isLiked).toBe(true); + + const responseUser3 = await getCommentById(commentId, user3.token); + expect(responseUser3.status).toEqual(200); + expect(responseUser3.body.isLiked).toBe(false); + }); + + it('should return comment with replies', async () => { + const { user, idea, token } = await setupTestUserAndIdea(); + const parentCommentId = await createTestComment( + 'Parent Comment', + idea.id, + token, + ); + + await createRepliesForComment( + parentCommentId, + idea.id, + user.profileId, + 3, + ); + + const response = await getCommentById(parentCommentId); + + expect(response.status).toEqual(200); + const body = response.body as CommentDto; + expect(body.replies).toEqual(3); + }); + + it('should return comment with likes', async () => { + const { idea, token } = await setupTestUserAndIdea( + 'testuser1', + 'test1@example.com', + ); + const [user2, user3, user4] = await setupMultipleTestUsers(3); + const commentId = await createTestComment( + 'Comment with multiple likes', + idea.id, + token, + ); + + await createLikesForComment(commentId, [ + user2.user.profileId, + user3.user.profileId, + user4.user.profileId, + ]); + + const response = await getCommentById(commentId); + + expect(response.status).toEqual(200); + const body = response.body as CommentDto; + expect(body.likes).toEqual(3); + }); + + it('should return comment without replies and likes', async () => { + const { idea, token } = await setupTestUserAndIdea(); + const commentId = await createTestComment( + 'Comment without replies and likes', + idea.id, + token, + ); + + const response = await getCommentById(commentId); + + expect(response.status).toEqual(200); + const body = response.body as CommentDto; + expect(body.replies).toEqual(0); + expect(body.likes).toEqual(0); + expect(body.isLiked).toBe(false); + }); + + it('should return reply comment (with parentCommentId)', async () => { + const { idea, token } = await setupTestUserAndIdea(); + const parentCommentId = await createTestComment( + 'Parent Comment', + idea.id, + token, + ); + const replyId = await createTestComment( + 'Reply Comment', + idea.id, + token, + parentCommentId, + ); + + const response = await getCommentById(replyId); + + expect(response.status).toEqual(200); + const body = response.body as CommentDto; + expect(body.content).toEqual('Reply Comment'); + expect(body.parentCommentId).toEqual(parentCommentId); + }); + + it('should return 400 when ID is invalid (not UUID)', async () => { + const response = await getCommentById('invalid-id'); + + expect(response.status).toBe(400); + const body = response.body as ErrorResponse; + expect(body.message).toContain('INVALID_ID'); + }); + + it('should return 404 when comment does not exist', async () => { + const nonExistentId = '1e9c20a4-3f8d-4b71-8c0e-92a15f0b6d2c'; + const response = await getCommentById(nonExistentId); + + expect(response.status).toBe(404); + const body = response.body as ErrorResponse; + expect(body.message).toContain('COMMENT_NOT_FOUND'); + }); + + it('should load author with user correctly', async () => { + const { user, idea, token } = await setupTestUserAndIdea( + 'testuser', + 'test@example.com', + 'idea title', + 'content', + ); + const commentId = await createTestComment( + 'Comment to test author', + idea.id, + token, + ); + + const response = await getCommentById(commentId); + + expect(response.status).toEqual(200); + const body = response.body as CommentDto; + expect(body.author).toBeDefined(); + expect(body.author.id).toEqual(user.profileId); + expect(body.author.displayName).toEqual('testuser'); + expect(body.author.user).toBeDefined(); + expect(body.author.user.username).toEqual('testuser'); + expect(body.author.user.email).toEqual('test@example.com'); + }); }); From 87bfc8e058f63bb1d526846605c8537a14c79aef Mon Sep 17 00:00:00 2001 From: gabriel Date: Sun, 30 Nov 2025 20:16:41 -0300 Subject: [PATCH 10/10] fix: lint --- .../v1/get-comment-by-id.spec.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/features/feed/comments/get-comment-by-id/v1/get-comment-by-id.spec.ts b/src/features/feed/comments/get-comment-by-id/v1/get-comment-by-id.spec.ts index bb53762..ae3c78a 100644 --- a/src/features/feed/comments/get-comment-by-id/v1/get-comment-by-id.spec.ts +++ b/src/features/feed/comments/get-comment-by-id/v1/get-comment-by-id.spec.ts @@ -166,10 +166,10 @@ describe('/v1/comment/:id (GET)', () => { }); it('should return comment and correct isLiked', async () => { - const { user: user1, idea, token } = await setupTestUserAndIdea( - 'testuser1', - 'test1@example.com', - ); + const { + idea, + token, + } = await setupTestUserAndIdea('testuser1', 'test1@example.com'); const [user2, user3] = await setupMultipleTestUsers(2); const commentId = await createTestComment( 'Comment with likes', @@ -196,12 +196,7 @@ describe('/v1/comment/:id (GET)', () => { token, ); - await createRepliesForComment( - parentCommentId, - idea.id, - user.profileId, - 3, - ); + await createRepliesForComment(parentCommentId, idea.id, user.profileId, 3); const response = await getCommentById(parentCommentId);