diff --git a/.env.example b/.env.example deleted file mode 100644 index bd3c4f0..0000000 --- a/.env.example +++ /dev/null @@ -1,13 +0,0 @@ -# Database Connection Config -DB_HOST= -DB_PORT= -DB_USER= -DB_PASS= -DB_NAME= - -# If DB_SYNC is true, the database will be synced with the entities on every start -# Set to false in production -DB_SYNC= - -# JWT Secret -JWT_SECRET= \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js index 259de13..0666d04 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -5,7 +5,9 @@ module.exports = { tsconfigRootDir: __dirname, sourceType: 'module', }, - plugins: ['@typescript-eslint/eslint-plugin'], + plugins: [ + '@typescript-eslint/eslint-plugin' + ], extends: [ 'plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended', @@ -22,4 +24,19 @@ module.exports = { '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-explicit-any': 'off', }, + 'prettier/prettier': [ + 'error', + { + arrowSpacing: ['error', { before: true, after: true }], + singleQuote: true, + semi: false, + useTabs: false, + tabWidth: 2, + trailingComma: 'none', + printWidth: 80, + bracketSpacing: true, + arrowParens: 'always', + endOfLine: 'auto', // 이 부분이 lf로 되어있다면 auto로 변경 + }, + ], }; diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml new file mode 100644 index 0000000..153c2bd --- /dev/null +++ b/.github/workflows/deploy-dev.yml @@ -0,0 +1,27 @@ +name: Development Server Deploy +on: + push: + branches: + - feature/#32 + - develop + pull_request: + branches: + - develop +jobs: + deploy: + name: Deploy + runs-on: ubuntu-latest + + steps: + - name: SSH + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USER }} + password: ${{ secrets.SSH_PASSWORD }} + script: | + cd /root/hereyou + git pull + npm install + npm run build + pm2 restart all \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..1ea4962 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "CodeGPT.apiKey": "CodeGPT Plus Beta" +} diff --git a/docs/pull-request-template.md b/docs/pull-request-template.md new file mode 100644 index 0000000..cf303c9 --- /dev/null +++ b/docs/pull-request-template.md @@ -0,0 +1,19 @@ +# Check + +- [ ] 잘 동작하는 코드인가요? +- [ ] 테스트를 충분히 해봤나요? +- [ ] 컨벤션을 준수 했나요? + +# Description + +어떤 작업을 했나요? + +# Key Changes + +핵심적인 부분을 간단하게 알려주세요! + +# To Reviewers + +이런 부분을 집중적으로 봐주세요. + +### 아직 해결하지 못한 사항이 있다면 issue에 남기고 팀원들과 공유해주세요~ diff --git a/package-lock.json b/package-lock.json index 7024466..f8db01e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,17 +9,31 @@ "version": "0.0.1", "license": "UNLICENSED", "dependencies": { - "@nestjs/common": "^10.0.0", + "@aws-sdk/client-s3": "^3.504.0", + "@aws-sdk/s3-request-presigner": "^3.504.0", + "@nestjs/common": "^10.3.1", "@nestjs/config": "^3.1.1", - "@nestjs/core": "^10.0.0", + "@nestjs/core": "^10.3.1", "@nestjs/mapped-types": "*", + "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.0.0", + "@nestjs/swagger": "^7.2.0", + "@nestjs/typeorm": "^10.0.1", + "aws-sdk": "^2.1550.0", "bcrypt": "^5.1.1", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "date-fns": "^3.3.1", "jsonwebtoken": "^9.0.2", - "mysql2": "^3.7.0", + "md5": "^2.3.0", + "multer": "^1.4.5-lts.1", + "multer-s3": "^3.0.1", + "mysql2": "^3.9.0", + "node-fetch": "^2.7.0", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", - "typeorm": "^0.3.19" + "typeorm": "^0.3.20", + "uuid": "^9.0.1" }, "devDependencies": { "@nestjs/cli": "^10.0.0", @@ -29,8 +43,12 @@ "@types/express": "^4.17.17", "@types/jest": "^29.5.2", "@types/jsonwebtoken": "^9.0.5", + "@types/md5": "^2.3.5", + "@types/multer-s3": "^3.0.3", "@types/node": "^20.3.1", + "@types/node-fetch": "^2.6.11", "@types/supertest": "^2.0.12", + "@types/uuid": "^9.0.8", "@typescript-eslint/eslint-plugin": "^5.59.11", "@typescript-eslint/parser": "^5.59.11", "eslint": "^8.42.0", @@ -241,6 +259,866 @@ "node": ">=0.12.0" } }, + "node_modules/@aws-crypto/crc32": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-3.0.0.tgz", + "integrity": "sha512-IzSgsrxUcsrejQbPVilIKy16kAT52EwB6zSaI+M3xxIhKh5+aldEyvI+z6erM7TCLB2BJsFrtHjp6/4/sr+3dA==", + "dependencies": { + "@aws-crypto/util": "^3.0.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-crypto/crc32/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@aws-crypto/crc32c": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-3.0.0.tgz", + "integrity": "sha512-ENNPPManmnVJ4BTXlOjAgD7URidbAznURqD0KvfREyc4o20DPYdEldU1f5cQ7Jbj0CJJSPaMIk/9ZshdB3210w==", + "dependencies": { + "@aws-crypto/util": "^3.0.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-crypto/crc32c/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@aws-crypto/ie11-detection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/ie11-detection/-/ie11-detection-3.0.0.tgz", + "integrity": "sha512-341lBBkiY1DfDNKai/wXM3aujNBkXR7tq1URPQDL9wi3AUbI80NR74uF1TXHMm7po1AcnFk8iu2S2IeU/+/A+Q==", + "dependencies": { + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-crypto/ie11-detection/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-3.0.0.tgz", + "integrity": "sha512-NJth5c997GLHs6nOYTzFKTbYdMNA6/1XlKVgnZoaZcQ7z7UJlOgj2JdbHE8tiYLS3fzXNCguct77SPGat2raSw==", + "dependencies": { + "@aws-crypto/ie11-detection": "^3.0.0", + "@aws-crypto/supports-web-crypto": "^3.0.0", + "@aws-crypto/util": "^3.0.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@aws-sdk/util-utf8-browser": "^3.0.0", + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-3.0.0.tgz", + "integrity": "sha512-8VLmW2B+gjFbU5uMeqtQM6Nj0/F1bro80xQXCW6CQBWgosFWXTx77aeOF5CAIAmbOK64SdMBJdNr6J41yP5mvQ==", + "dependencies": { + "@aws-crypto/ie11-detection": "^3.0.0", + "@aws-crypto/sha256-js": "^3.0.0", + "@aws-crypto/supports-web-crypto": "^3.0.0", + "@aws-crypto/util": "^3.0.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@aws-sdk/util-utf8-browser": "^3.0.0", + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-3.0.0.tgz", + "integrity": "sha512-PnNN7os0+yd1XvXAy23CFOmTbMaDxgxXtTKHybrJ39Y8kGzBATgBFibWJKH6BhytLI/Zyszs87xCOBNyBig6vQ==", + "dependencies": { + "@aws-crypto/util": "^3.0.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-crypto/sha256-js/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-3.0.0.tgz", + "integrity": "sha512-06hBdMwUAb2WFTuGG73LSC0wfPu93xWwo5vL2et9eymgmu3Id5vFAHBbajVWiGhPO37qcsdCap/FqXvJGJWPIg==", + "dependencies": { + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-crypto/supports-web-crypto/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@aws-crypto/util": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-3.0.0.tgz", + "integrity": "sha512-2OJlpeJpCR48CC8r+uKVChzs9Iungj9wkZrl8Z041DWEWvyIHILYKCPNzJghKsivj+S3mLo6BVc7mBNzdxA46w==", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-utf8-browser": "^3.0.0", + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-crypto/util/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.504.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.504.0.tgz", + "integrity": "sha512-J8xPsnk7EDwalFSaDxPFNT2+x99nG2uQTpsLXAV3bWbT1nD/JZ+fase9GqxM11v6WngzqRvTQg26ljMn5hQSKA==", + "dependencies": { + "@aws-crypto/sha1-browser": "3.0.0", + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/client-sts": "3.504.0", + "@aws-sdk/core": "3.496.0", + "@aws-sdk/credential-provider-node": "3.504.0", + "@aws-sdk/middleware-bucket-endpoint": "3.502.0", + "@aws-sdk/middleware-expect-continue": "3.502.0", + "@aws-sdk/middleware-flexible-checksums": "3.502.0", + "@aws-sdk/middleware-host-header": "3.502.0", + "@aws-sdk/middleware-location-constraint": "3.502.0", + "@aws-sdk/middleware-logger": "3.502.0", + "@aws-sdk/middleware-recursion-detection": "3.502.0", + "@aws-sdk/middleware-sdk-s3": "3.502.0", + "@aws-sdk/middleware-signing": "3.502.0", + "@aws-sdk/middleware-ssec": "3.502.0", + "@aws-sdk/middleware-user-agent": "3.502.0", + "@aws-sdk/region-config-resolver": "3.502.0", + "@aws-sdk/signature-v4-multi-region": "3.502.0", + "@aws-sdk/types": "3.502.0", + "@aws-sdk/util-endpoints": "3.502.0", + "@aws-sdk/util-user-agent-browser": "3.502.0", + "@aws-sdk/util-user-agent-node": "3.502.0", + "@aws-sdk/xml-builder": "3.496.0", + "@smithy/config-resolver": "^2.1.1", + "@smithy/core": "^1.3.1", + "@smithy/eventstream-serde-browser": "^2.1.1", + "@smithy/eventstream-serde-config-resolver": "^2.1.1", + "@smithy/eventstream-serde-node": "^2.1.1", + "@smithy/fetch-http-handler": "^2.4.1", + "@smithy/hash-blob-browser": "^2.1.1", + "@smithy/hash-node": "^2.1.1", + "@smithy/hash-stream-node": "^2.1.1", + "@smithy/invalid-dependency": "^2.1.1", + "@smithy/md5-js": "^2.1.1", + "@smithy/middleware-content-length": "^2.1.1", + "@smithy/middleware-endpoint": "^2.4.1", + "@smithy/middleware-retry": "^2.1.1", + "@smithy/middleware-serde": "^2.1.1", + "@smithy/middleware-stack": "^2.1.1", + "@smithy/node-config-provider": "^2.2.1", + "@smithy/node-http-handler": "^2.3.1", + "@smithy/protocol-http": "^3.1.1", + "@smithy/smithy-client": "^2.3.1", + "@smithy/types": "^2.9.1", + "@smithy/url-parser": "^2.1.1", + "@smithy/util-base64": "^2.1.1", + "@smithy/util-body-length-browser": "^2.1.1", + "@smithy/util-body-length-node": "^2.2.1", + "@smithy/util-defaults-mode-browser": "^2.1.1", + "@smithy/util-defaults-mode-node": "^2.1.1", + "@smithy/util-endpoints": "^1.1.1", + "@smithy/util-retry": "^2.1.1", + "@smithy/util-stream": "^2.1.1", + "@smithy/util-utf8": "^2.1.1", + "@smithy/util-waiter": "^2.1.1", + "fast-xml-parser": "4.2.5", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-sso": { + "version": "3.502.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.502.0.tgz", + "integrity": "sha512-OZAYal1+PQgUUtWiHhRayDtX0OD+XpXHKAhjYgEIPbyhQaCMp3/Bq1xDX151piWXvXqXLJHFKb8DUEqzwGO9QA==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/core": "3.496.0", + "@aws-sdk/middleware-host-header": "3.502.0", + "@aws-sdk/middleware-logger": "3.502.0", + "@aws-sdk/middleware-recursion-detection": "3.502.0", + "@aws-sdk/middleware-user-agent": "3.502.0", + "@aws-sdk/region-config-resolver": "3.502.0", + "@aws-sdk/types": "3.502.0", + "@aws-sdk/util-endpoints": "3.502.0", + "@aws-sdk/util-user-agent-browser": "3.502.0", + "@aws-sdk/util-user-agent-node": "3.502.0", + "@smithy/config-resolver": "^2.1.1", + "@smithy/core": "^1.3.1", + "@smithy/fetch-http-handler": "^2.4.1", + "@smithy/hash-node": "^2.1.1", + "@smithy/invalid-dependency": "^2.1.1", + "@smithy/middleware-content-length": "^2.1.1", + "@smithy/middleware-endpoint": "^2.4.1", + "@smithy/middleware-retry": "^2.1.1", + "@smithy/middleware-serde": "^2.1.1", + "@smithy/middleware-stack": "^2.1.1", + "@smithy/node-config-provider": "^2.2.1", + "@smithy/node-http-handler": "^2.3.1", + "@smithy/protocol-http": "^3.1.1", + "@smithy/smithy-client": "^2.3.1", + "@smithy/types": "^2.9.1", + "@smithy/url-parser": "^2.1.1", + "@smithy/util-base64": "^2.1.1", + "@smithy/util-body-length-browser": "^2.1.1", + "@smithy/util-body-length-node": "^2.2.1", + "@smithy/util-defaults-mode-browser": "^2.1.1", + "@smithy/util-defaults-mode-node": "^2.1.1", + "@smithy/util-endpoints": "^1.1.1", + "@smithy/util-retry": "^2.1.1", + "@smithy/util-utf8": "^2.1.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-sso-oidc": { + "version": "3.504.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.504.0.tgz", + "integrity": "sha512-ODA33/nm2srhV08EW0KZAP577UgV0qjyr7Xp2yEo8MXWL4ZqQZprk1c+QKBhjr4Djesrm0VPmSD/np0mtYP68A==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/client-sts": "3.504.0", + "@aws-sdk/core": "3.496.0", + "@aws-sdk/middleware-host-header": "3.502.0", + "@aws-sdk/middleware-logger": "3.502.0", + "@aws-sdk/middleware-recursion-detection": "3.502.0", + "@aws-sdk/middleware-signing": "3.502.0", + "@aws-sdk/middleware-user-agent": "3.502.0", + "@aws-sdk/region-config-resolver": "3.502.0", + "@aws-sdk/types": "3.502.0", + "@aws-sdk/util-endpoints": "3.502.0", + "@aws-sdk/util-user-agent-browser": "3.502.0", + "@aws-sdk/util-user-agent-node": "3.502.0", + "@smithy/config-resolver": "^2.1.1", + "@smithy/core": "^1.3.1", + "@smithy/fetch-http-handler": "^2.4.1", + "@smithy/hash-node": "^2.1.1", + "@smithy/invalid-dependency": "^2.1.1", + "@smithy/middleware-content-length": "^2.1.1", + "@smithy/middleware-endpoint": "^2.4.1", + "@smithy/middleware-retry": "^2.1.1", + "@smithy/middleware-serde": "^2.1.1", + "@smithy/middleware-stack": "^2.1.1", + "@smithy/node-config-provider": "^2.2.1", + "@smithy/node-http-handler": "^2.3.1", + "@smithy/protocol-http": "^3.1.1", + "@smithy/smithy-client": "^2.3.1", + "@smithy/types": "^2.9.1", + "@smithy/url-parser": "^2.1.1", + "@smithy/util-base64": "^2.1.1", + "@smithy/util-body-length-browser": "^2.1.1", + "@smithy/util-body-length-node": "^2.2.1", + "@smithy/util-defaults-mode-browser": "^2.1.1", + "@smithy/util-defaults-mode-node": "^2.1.1", + "@smithy/util-endpoints": "^1.1.1", + "@smithy/util-retry": "^2.1.1", + "@smithy/util-utf8": "^2.1.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-node": "^3.504.0" + } + }, + "node_modules/@aws-sdk/client-sts": { + "version": "3.504.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.504.0.tgz", + "integrity": "sha512-IESs8FkL7B/uY+ml4wgoRkrr6xYo4PizcNw6JX17eveq1gRBCPKeGMjE6HTDOcIYZZ8rqz/UeuH3JD4UhrMOnA==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/core": "3.496.0", + "@aws-sdk/middleware-host-header": "3.502.0", + "@aws-sdk/middleware-logger": "3.502.0", + "@aws-sdk/middleware-recursion-detection": "3.502.0", + "@aws-sdk/middleware-user-agent": "3.502.0", + "@aws-sdk/region-config-resolver": "3.502.0", + "@aws-sdk/types": "3.502.0", + "@aws-sdk/util-endpoints": "3.502.0", + "@aws-sdk/util-user-agent-browser": "3.502.0", + "@aws-sdk/util-user-agent-node": "3.502.0", + "@smithy/config-resolver": "^2.1.1", + "@smithy/core": "^1.3.1", + "@smithy/fetch-http-handler": "^2.4.1", + "@smithy/hash-node": "^2.1.1", + "@smithy/invalid-dependency": "^2.1.1", + "@smithy/middleware-content-length": "^2.1.1", + "@smithy/middleware-endpoint": "^2.4.1", + "@smithy/middleware-retry": "^2.1.1", + "@smithy/middleware-serde": "^2.1.1", + "@smithy/middleware-stack": "^2.1.1", + "@smithy/node-config-provider": "^2.2.1", + "@smithy/node-http-handler": "^2.3.1", + "@smithy/protocol-http": "^3.1.1", + "@smithy/smithy-client": "^2.3.1", + "@smithy/types": "^2.9.1", + "@smithy/url-parser": "^2.1.1", + "@smithy/util-base64": "^2.1.1", + "@smithy/util-body-length-browser": "^2.1.1", + "@smithy/util-body-length-node": "^2.2.1", + "@smithy/util-defaults-mode-browser": "^2.1.1", + "@smithy/util-defaults-mode-node": "^2.1.1", + "@smithy/util-endpoints": "^1.1.1", + "@smithy/util-middleware": "^2.1.1", + "@smithy/util-retry": "^2.1.1", + "@smithy/util-utf8": "^2.1.1", + "fast-xml-parser": "4.2.5", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-node": "^3.504.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.496.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.496.0.tgz", + "integrity": "sha512-yT+ug7Cw/3eJi7x2es0+46x12+cIJm5Xv+GPWsrTFD1TKgqO/VPEgfDtHFagDNbFmjNQA65Ygc/kEdIX9ICX/A==", + "dependencies": { + "@smithy/core": "^1.3.1", + "@smithy/protocol-http": "^3.1.1", + "@smithy/signature-v4": "^2.1.1", + "@smithy/smithy-client": "^2.3.1", + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.502.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.502.0.tgz", + "integrity": "sha512-KIB8Ae1Z7domMU/jU4KiIgK4tmYgvuXlhR54ehwlVHxnEoFPoPuGHFZU7oFn79jhhSLUFQ1lRYMxP0cEwb7XeQ==", + "dependencies": { + "@aws-sdk/types": "3.502.0", + "@smithy/property-provider": "^2.1.1", + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.503.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.503.1.tgz", + "integrity": "sha512-rTdlFFGoPPFMF2YjtlfRuSgKI+XsF49u7d98255hySwhsbwd3Xp+utTTPquxP+CwDxMHbDlI7NxDzFiFdsoZug==", + "dependencies": { + "@aws-sdk/types": "3.502.0", + "@smithy/fetch-http-handler": "^2.4.1", + "@smithy/node-http-handler": "^2.3.1", + "@smithy/property-provider": "^2.1.1", + "@smithy/protocol-http": "^3.1.1", + "@smithy/smithy-client": "^2.3.1", + "@smithy/types": "^2.9.1", + "@smithy/util-stream": "^2.1.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.504.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.504.0.tgz", + "integrity": "sha512-ODICLXfr8xTUd3wweprH32Ge41yuBa+u3j0JUcLdTUO1N9ldczSMdo8zOPlP0z4doqD3xbnqMkjNQWgN/Q+5oQ==", + "dependencies": { + "@aws-sdk/client-sts": "3.504.0", + "@aws-sdk/credential-provider-env": "3.502.0", + "@aws-sdk/credential-provider-process": "3.502.0", + "@aws-sdk/credential-provider-sso": "3.504.0", + "@aws-sdk/credential-provider-web-identity": "3.504.0", + "@aws-sdk/types": "3.502.0", + "@smithy/credential-provider-imds": "^2.2.1", + "@smithy/property-provider": "^2.1.1", + "@smithy/shared-ini-file-loader": "^2.3.1", + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.504.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.504.0.tgz", + "integrity": "sha512-6+V5hIh+tILmUjf2ZQWQINR3atxQVgH/bFrGdSR/sHSp/tEgw3m0xWL3IRslWU1e4/GtXrfg1iYnMknXy68Ikw==", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.502.0", + "@aws-sdk/credential-provider-http": "3.503.1", + "@aws-sdk/credential-provider-ini": "3.504.0", + "@aws-sdk/credential-provider-process": "3.502.0", + "@aws-sdk/credential-provider-sso": "3.504.0", + "@aws-sdk/credential-provider-web-identity": "3.504.0", + "@aws-sdk/types": "3.502.0", + "@smithy/credential-provider-imds": "^2.2.1", + "@smithy/property-provider": "^2.1.1", + "@smithy/shared-ini-file-loader": "^2.3.1", + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.502.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.502.0.tgz", + "integrity": "sha512-fJJowOjQ4infYQX0E1J3xFVlmuwEYJAFk0Mo1qwafWmEthsBJs+6BR2RiWDELHKrSK35u4Pf3fu3RkYuCtmQFw==", + "dependencies": { + "@aws-sdk/types": "3.502.0", + "@smithy/property-provider": "^2.1.1", + "@smithy/shared-ini-file-loader": "^2.3.1", + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.504.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.504.0.tgz", + "integrity": "sha512-4MgH2or2SjPzaxM08DCW+BjaX4DSsEGJlicHKmz6fh+w9JmLh750oXcTnbvgUeVz075jcs6qTKjvUcsdGM/t8Q==", + "dependencies": { + "@aws-sdk/client-sso": "3.502.0", + "@aws-sdk/token-providers": "3.504.0", + "@aws-sdk/types": "3.502.0", + "@smithy/property-provider": "^2.1.1", + "@smithy/shared-ini-file-loader": "^2.3.1", + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.504.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.504.0.tgz", + "integrity": "sha512-L1ljCvGpIEFdJk087ijf2ohg7HBclOeB1UgBxUBBzf4iPRZTQzd2chGaKj0hm2VVaXz7nglswJeURH5PFcS5oA==", + "dependencies": { + "@aws-sdk/client-sts": "3.504.0", + "@aws-sdk/types": "3.502.0", + "@smithy/property-provider": "^2.1.1", + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/lib-storage": { + "version": "3.504.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.504.0.tgz", + "integrity": "sha512-A2h/yHy+2JFhqiCL1vfSlKxLRIZyyQte58O8s0yAV/TDt7ElzeXMTVtCUvhcOrnjtdHKfh4F36jeZSh1ja/9HA==", + "dependencies": { + "@smithy/abort-controller": "^2.1.1", + "@smithy/middleware-endpoint": "^2.4.1", + "@smithy/smithy-client": "^2.3.1", + "buffer": "5.6.0", + "events": "3.3.0", + "stream-browserify": "3.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-s3": "^3.0.0" + } + }, + "node_modules/@aws-sdk/lib-storage/node_modules/buffer": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz", + "integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==", + "dependencies": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4" + } + }, + "node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.502.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.502.0.tgz", + "integrity": "sha512-mUSP2DUcjhO5zM2b21CvZ9AqwI8DaAeZA6NYHOxWGTV9BUxHcdGWXEjDkcVj9CQ0gvNwTtw6B5L/q52rVAnZbw==", + "dependencies": { + "@aws-sdk/types": "3.502.0", + "@aws-sdk/util-arn-parser": "3.495.0", + "@smithy/node-config-provider": "^2.2.1", + "@smithy/protocol-http": "^3.1.1", + "@smithy/types": "^2.9.1", + "@smithy/util-config-provider": "^2.2.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/middleware-expect-continue": { + "version": "3.502.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.502.0.tgz", + "integrity": "sha512-DxfAuBVuPSt8as9xP57o8ks6ySVSjwO2NNNAdpLwk4KhEAPYEpHlf2yWYorYLrS+dDmwfYgOhRNoguuBdCu6ow==", + "dependencies": { + "@aws-sdk/types": "3.502.0", + "@smithy/protocol-http": "^3.1.1", + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.502.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.502.0.tgz", + "integrity": "sha512-kCt2zQDFumz/LnJJJOSd2GW4dr8oT8YMJKgxC/pph3aRXoSHXRwhrMbFnQ8swEE9vjywxtcED8sym0b0tNhhoA==", + "dependencies": { + "@aws-crypto/crc32": "3.0.0", + "@aws-crypto/crc32c": "3.0.0", + "@aws-sdk/types": "3.502.0", + "@smithy/is-array-buffer": "^2.1.1", + "@smithy/protocol-http": "^3.1.1", + "@smithy/types": "^2.9.1", + "@smithy/util-utf8": "^2.1.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.502.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.502.0.tgz", + "integrity": "sha512-EjnG0GTYXT/wJBmm5/mTjDcAkzU8L7wQjOzd3FTXuTCNNyvAvwrszbOj5FlarEw5XJBbQiZtBs+I5u9+zy560w==", + "dependencies": { + "@aws-sdk/types": "3.502.0", + "@smithy/protocol-http": "^3.1.1", + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/middleware-location-constraint": { + "version": "3.502.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.502.0.tgz", + "integrity": "sha512-fLRwPuTZvEWQkPjys03m3D6tYN4kf7zU6+c8mJxwvEg+yfBuv2RBsbd+Vn2bTisUjXvIg1kyBzONlpHoIyFneg==", + "dependencies": { + "@aws-sdk/types": "3.502.0", + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.502.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.502.0.tgz", + "integrity": "sha512-FDyv6K4nCoHxbjLGS2H8ex8I0KDIiu4FJgVRPs140ZJy6gE5Pwxzv6YTzZGLMrnqcIs9gh065Lf6DjwMelZqaw==", + "dependencies": { + "@aws-sdk/types": "3.502.0", + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.502.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.502.0.tgz", + "integrity": "sha512-hvbyGJbxeuezxOu8VfFmcV4ql1hKXLxHTe5FNYfEBat2KaZXVhc1Hg+4TvB06/53p+E8J99Afmumkqbxs2esUA==", + "dependencies": { + "@aws-sdk/types": "3.502.0", + "@smithy/protocol-http": "^3.1.1", + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.502.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.502.0.tgz", + "integrity": "sha512-GbGugrfyL5bNA/zw8iQll92yXBONfWSC8Ns00DtkOU1saPXp4/7WHtyyZGYdvPa73T1IsuZy9egpoYRBmRcd5Q==", + "dependencies": { + "@aws-sdk/types": "3.502.0", + "@aws-sdk/util-arn-parser": "3.495.0", + "@smithy/node-config-provider": "^2.2.1", + "@smithy/protocol-http": "^3.1.1", + "@smithy/signature-v4": "^2.1.1", + "@smithy/smithy-client": "^2.3.1", + "@smithy/types": "^2.9.1", + "@smithy/util-config-provider": "^2.2.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/middleware-signing": { + "version": "3.502.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-signing/-/middleware-signing-3.502.0.tgz", + "integrity": "sha512-4hF08vSzJ7L6sB+393gOFj3s2N6nLusYS0XrMW6wYNFU10IDdbf8Z3TZ7gysDJJHEGQPmTAesPEDBsasGWcMxg==", + "dependencies": { + "@aws-sdk/types": "3.502.0", + "@smithy/property-provider": "^2.1.1", + "@smithy/protocol-http": "^3.1.1", + "@smithy/signature-v4": "^2.1.1", + "@smithy/types": "^2.9.1", + "@smithy/util-middleware": "^2.1.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/middleware-ssec": { + "version": "3.502.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.502.0.tgz", + "integrity": "sha512-1nidVTIba6/aVjjzD/WNqWdzSyTrXOHO3Ddz2MGD8S1yGSrYz4iYaq4Bm/uosfdr8B1L0Ws0pjdRXrNfzSw/DQ==", + "dependencies": { + "@aws-sdk/types": "3.502.0", + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.502.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.502.0.tgz", + "integrity": "sha512-TxbBZbRiXPH0AUxegqiNd9aM9zNSbfjtBs5MEfcBsweeT/B2O7K1EjP9+CkB8Xmk/5FLKhAKLr19b1TNoE27rw==", + "dependencies": { + "@aws-sdk/types": "3.502.0", + "@aws-sdk/util-endpoints": "3.502.0", + "@smithy/protocol-http": "^3.1.1", + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.502.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.502.0.tgz", + "integrity": "sha512-mxmsX2AGgnSM+Sah7mcQCIneOsJQNiLX0COwEttuf8eO+6cLMAZvVudH3BnWTfea4/A9nuri9DLCqBvEmPrilg==", + "dependencies": { + "@aws-sdk/types": "3.502.0", + "@smithy/node-config-provider": "^2.2.1", + "@smithy/types": "^2.9.1", + "@smithy/util-config-provider": "^2.2.1", + "@smithy/util-middleware": "^2.1.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/s3-request-presigner": { + "version": "3.504.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.504.0.tgz", + "integrity": "sha512-5FxVdRufiFLSUDJ/Qul5JFPHjhFFzo+C6u53bzbi7gaSshA6lLLhJ9KbVk2LmKE1mTR+nh2+JebI6y+3njtkzw==", + "dependencies": { + "@aws-sdk/signature-v4-multi-region": "3.502.0", + "@aws-sdk/types": "3.502.0", + "@aws-sdk/util-format-url": "3.502.0", + "@smithy/middleware-endpoint": "^2.4.1", + "@smithy/protocol-http": "^3.1.1", + "@smithy/smithy-client": "^2.3.1", + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.502.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.502.0.tgz", + "integrity": "sha512-NpOXtUXH0ZAgnyI3Y3s2fPrgwbsWoNMwdoXdFZvH0eDzzX80tim7Yuy6dzVA5zrxSzOYs1xjcOhM+4CmM0QZiw==", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "3.502.0", + "@aws-sdk/types": "3.502.0", + "@smithy/protocol-http": "^3.1.1", + "@smithy/signature-v4": "^2.1.1", + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.504.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.504.0.tgz", + "integrity": "sha512-YIJWWsZi2ClUiILS1uh5L6VjmCUSTI6KKMuL9DkGjYqJ0aI6M8bd8fT9Wm7QmXCyjcArTgr/Atkhia4T7oKvzQ==", + "dependencies": { + "@aws-sdk/client-sso-oidc": "3.504.0", + "@aws-sdk/types": "3.502.0", + "@smithy/property-provider": "^2.1.1", + "@smithy/shared-ini-file-loader": "^2.3.1", + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.502.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.502.0.tgz", + "integrity": "sha512-M0DSPYe/gXhwD2QHgoukaZv5oDxhW3FfvYIrJptyqUq3OnPJBcDbihHjrE0PBtfh/9kgMZT60/fQ2NVFANfa2g==", + "dependencies": { + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.495.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.495.0.tgz", + "integrity": "sha512-hwdA3XAippSEUxs7jpznwD63YYFR+LtQvlEcebPTgWR9oQgG9TfS+39PUfbnEeje1ICuOrN3lrFqFbmP9uzbMg==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.502.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.502.0.tgz", + "integrity": "sha512-6LKFlJPp2J24r1Kpfoz5ESQn+1v5fEjDB3mtUKRdpwarhm3syu7HbKlHCF3KbcCOyahobvLvhoedT78rJFEeeg==", + "dependencies": { + "@aws-sdk/types": "3.502.0", + "@smithy/types": "^2.9.1", + "@smithy/util-endpoints": "^1.1.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/util-format-url": { + "version": "3.502.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.502.0.tgz", + "integrity": "sha512-4+0zBD0ZIJqtTzSE6VRruRwUx3lG+is8Egv+LN99X5y7i6OdrS9ePYHbCJ9FxkzTThgbkUq6k2W7psEDYvn4VA==", + "dependencies": { + "@aws-sdk/types": "3.502.0", + "@smithy/querystring-builder": "^2.1.1", + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.495.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.495.0.tgz", + "integrity": "sha512-MfaPXT0kLX2tQaR90saBT9fWQq2DHqSSJRzW+MZWsmF+y5LGCOhO22ac/2o6TKSQm7h0HRc2GaADqYYYor62yg==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.502.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.502.0.tgz", + "integrity": "sha512-v8gKyCs2obXoIkLETAeEQ3AM+QmhHhst9xbM1cJtKUGsRlVIak/XyyD+kVE6kmMm1cjfudHpHKABWk9apQcIZQ==", + "dependencies": { + "@aws-sdk/types": "3.502.0", + "@smithy/types": "^2.9.1", + "bowser": "^2.11.0", + "tslib": "^2.5.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.502.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.502.0.tgz", + "integrity": "sha512-9RjxpkGZKbTdl96tIJvAo+vZoz4P/cQh36SBUt9xfRfW0BtsaLyvSrvlR5wyUYhvRcC12Axqh/8JtnAPq//+Vw==", + "dependencies": { + "@aws-sdk/types": "3.502.0", + "@smithy/node-config-provider": "^2.2.1", + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/util-utf8-browser": { + "version": "3.259.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-utf8-browser/-/util-utf8-browser-3.259.0.tgz", + "integrity": "sha512-UvFa/vR+e19XookZF8RzFZBrw2EUkQWxiBW0yYQAhvk3C+QVGl0H3ouca8LDBlBfQKXwmW3huo/59H8rwb1wJw==", + "dependencies": { + "tslib": "^2.3.1" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.496.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.496.0.tgz", + "integrity": "sha512-GvEjh537IIeOw1ZkZuB37sV12u+ipS5Z1dwjEC/HAvhl5ac23ULtTr1/n+U1gLNN+BAKSWjKiQ2ksj8DiUzeyw==", + "dependencies": { + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.23.5", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", @@ -1729,9 +2607,9 @@ } }, "node_modules/@nestjs/common": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.3.0.tgz", - "integrity": "sha512-DGv34UHsZBxCM3H5QGE2XE/+oLJzz5+714JQjBhjD9VccFlQs3LRxo/epso4l7nJIiNlZkPyIUC8WzfU/5RTsQ==", + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.3.1.tgz", + "integrity": "sha512-YuxeIlVemVQCuXMkNbBpNlmwZgp/Cu6dwCOjki63mhyYHEFX48GNNA4zZn5MFRjF4h7VSceABsScROuzsxs9LA==", "dependencies": { "iterare": "1.2.1", "tslib": "2.6.2", @@ -1771,10 +2649,18 @@ "reflect-metadata": "^0.1.13" } }, + "node_modules/@nestjs/config/node_modules/uuid": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", + "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@nestjs/core": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.3.0.tgz", - "integrity": "sha512-N06P5ncknW/Pm8bj964WvLIZn2gNhHliCBoAO1LeBvNImYkecqKcrmLbY49Fa1rmMfEM3MuBHeDys3edeuYAOA==", + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.3.1.tgz", + "integrity": "sha512-mh6FwTKh2R3CmLRuB50BF5q/lzc+Mz+7qAlEvpgCiTSIfSXzbQ47vWpfgLirwkL3SlCvtFS8onxOeI69RpxvXA==", "hasInstallScript": true, "dependencies": { "@nuxtjs/opencollective": "0.3.2", @@ -1827,6 +2713,15 @@ } } }, + "node_modules/@nestjs/passport": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-10.0.3.tgz", + "integrity": "sha512-znJ9Y4S8ZDVY+j4doWAJ8EuuVO7SkQN3yOBmzxbGaXbvcSwFDAdGJ+OMCg52NdzIO4tQoN4pYKx8W6M0ArfFRQ==", + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "passport": "^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0" + } + }, "node_modules/@nestjs/platform-express": { "version": "10.3.0", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.3.0.tgz", @@ -1847,11 +2742,28 @@ "@nestjs/core": "^10.0.0" } }, - "node_modules/@nestjs/schematics": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.1.0.tgz", - "integrity": "sha512-HQWvD3F7O0Sv3qHS2jineWxPLmBTLlyjT6VdSw2EAIXulitmV+ErxB3TCVQQORlNkl5p5cwRYWyBaOblDbNFIQ==", - "dev": true, + "node_modules/@nestjs/platform-express/node_modules/multer": { + "version": "1.4.4-lts.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.4-lts.1.tgz", + "integrity": "sha512-WeSGziVj6+Z2/MwQo3GvqzgR+9Uc+qt8SwHKh3gvNPiISKfsMfG4SvCOFYlxxgkXt7yIV2i1yczehm0EOKIxIg==", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/@nestjs/schematics": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.1.0.tgz", + "integrity": "sha512-HQWvD3F7O0Sv3qHS2jineWxPLmBTLlyjT6VdSw2EAIXulitmV+ErxB3TCVQQORlNkl5p5cwRYWyBaOblDbNFIQ==", + "dev": true, "dependencies": { "@angular-devkit/core": "17.0.9", "@angular-devkit/schematics": "17.0.9", @@ -1863,6 +2775,37 @@ "typescript": ">=4.8.2" } }, + "node_modules/@nestjs/swagger": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.2.0.tgz", + "integrity": "sha512-W7WPq561/79w27ZEgViXS7c5hqPwT7QXhsLsSeu2jeBROUhMM825QKDFKbMmtb643IW5dznJ4PjherlZZgtMvg==", + "dependencies": { + "@nestjs/mapped-types": "2.0.4", + "js-yaml": "4.1.0", + "lodash": "4.17.21", + "path-to-regexp": "3.2.0", + "swagger-ui-dist": "5.11.0" + }, + "peerDependencies": { + "@fastify/static": "^6.0.0", + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", + "class-transformer": "*", + "class-validator": "*", + "reflect-metadata": "^0.1.12" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, "node_modules/@nestjs/testing": { "version": "10.3.0", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.3.0.tgz", @@ -1890,6 +2833,21 @@ } } }, + "node_modules/@nestjs/typeorm": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-10.0.1.tgz", + "integrity": "sha512-YVFYL7D25VAVp5/G+KLXIgsRfYomA+VaFZBpm2rtwrrBOmkXNrxr7kuI2bBBO/Xy4kKBDe6wbvIVVFeEA7/ngA==", + "dependencies": { + "uuid": "9.0.1" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0", + "reflect-metadata": "^0.1.13", + "rxjs": "^7.2.0", + "typeorm": "^0.3.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1966,13 +2924,658 @@ "type-detect": "4.0.8" } }, - "node_modules/@sinonjs/fake-timers": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", - "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", - "dev": true, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@smithy/abort-controller": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-2.1.1.tgz", + "integrity": "sha512-1+qdrUqLhaALYL0iOcN43EP6yAXXQ2wWZ6taf4S2pNGowmOc5gx+iMQv+E42JizNJjB0+gEadOXeV1Bf7JWL1Q==", + "dependencies": { + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-2.1.1.tgz", + "integrity": "sha512-NjNFCKxC4jVvn+lUr3Yo4/PmUJj3tbyqH6GNHueyTGS5Q27vlEJ1MkNhUDV8QGxJI7Bodnc2pD18lU2zRfhHlQ==", + "dependencies": { + "tslib": "^2.5.0" + } + }, + "node_modules/@smithy/chunked-blob-reader-native": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-2.1.1.tgz", + "integrity": "sha512-zNW+43dltfNMUrBEYLMWgI8lQr0uhtTcUyxkgC9EP4j17WREzgSFMPUFVrVV6Rc2+QtWERYjb4tzZnQGa7R9fQ==", + "dependencies": { + "@smithy/util-base64": "^2.1.1", + "tslib": "^2.5.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-2.1.1.tgz", + "integrity": "sha512-lxfLDpZm+AWAHPFZps5JfDoO9Ux1764fOgvRUBpHIO8HWHcSN1dkgsago1qLRVgm1BZ8RCm8cgv99QvtaOWIhw==", + "dependencies": { + "@smithy/node-config-provider": "^2.2.1", + "@smithy/types": "^2.9.1", + "@smithy/util-config-provider": "^2.2.1", + "@smithy/util-middleware": "^2.1.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-1.3.1.tgz", + "integrity": "sha512-tf+NIu9FkOh312b6M9G4D68is4Xr7qptzaZGZUREELF8ysE1yLKphqt7nsomjKZVwW7WE5pDDex9idowNGRQ/Q==", + "dependencies": { + "@smithy/middleware-endpoint": "^2.4.1", + "@smithy/middleware-retry": "^2.1.1", + "@smithy/middleware-serde": "^2.1.1", + "@smithy/protocol-http": "^3.1.1", + "@smithy/smithy-client": "^2.3.1", + "@smithy/types": "^2.9.1", + "@smithy/util-middleware": "^2.1.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-2.2.1.tgz", + "integrity": "sha512-7XHjZUxmZYnONheVQL7j5zvZXga+EWNgwEAP6OPZTi7l8J4JTeNh9aIOfE5fKHZ/ee2IeNOh54ZrSna+Vc6TFA==", + "dependencies": { + "@smithy/node-config-provider": "^2.2.1", + "@smithy/property-provider": "^2.1.1", + "@smithy/types": "^2.9.1", + "@smithy/url-parser": "^2.1.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-2.1.1.tgz", + "integrity": "sha512-E8KYBxBIuU4c+zrpR22VsVrOPoEDzk35bQR3E+xm4k6Pa6JqzkDOdMyf9Atac5GPNKHJBdVaQ4JtjdWX2rl/nw==", + "dependencies": { + "@aws-crypto/crc32": "3.0.0", + "@smithy/types": "^2.9.1", + "@smithy/util-hex-encoding": "^2.1.1", + "tslib": "^2.5.0" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-2.1.1.tgz", + "integrity": "sha512-JvEdCmGlZUay5VtlT8/kdR6FlvqTDUiJecMjXsBb0+k1H/qc9ME5n2XKPo8q/MZwEIA1GmGgYMokKGjVvMiDow==", + "dependencies": { + "@smithy/eventstream-serde-universal": "^2.1.1", + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-2.1.1.tgz", + "integrity": "sha512-EqNqXYp3+dk//NmW3NAgQr9bEQ7fsu/CcxQmTiq07JlaIcne/CBWpMZETyXm9w5LXkhduBsdXdlMscfDUDn2fA==", + "dependencies": { + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-node": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-2.1.1.tgz", + "integrity": "sha512-LF882q/aFidFNDX7uROAGxq3H0B7rjyPkV6QDn6/KDQ+CG7AFkRccjxRf1xqajq/Pe4bMGGr+VKAaoF6lELIQw==", + "dependencies": { + "@smithy/eventstream-serde-universal": "^2.1.1", + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-2.1.1.tgz", + "integrity": "sha512-LR0mMT+XIYTxk4k2fIxEA1BPtW3685QlqufUEUAX1AJcfFfxNDKEvuCRZbO8ntJb10DrIFVJR9vb0MhDCi0sAQ==", + "dependencies": { + "@smithy/eventstream-codec": "^2.1.1", + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-2.4.1.tgz", + "integrity": "sha512-VYGLinPsFqH68lxfRhjQaSkjXM7JysUOJDTNjHBuN/ykyRb2f1gyavN9+VhhPTWCy32L4yZ2fdhpCs/nStEicg==", + "dependencies": { + "@smithy/protocol-http": "^3.1.1", + "@smithy/querystring-builder": "^2.1.1", + "@smithy/types": "^2.9.1", + "@smithy/util-base64": "^2.1.1", + "tslib": "^2.5.0" + } + }, + "node_modules/@smithy/hash-blob-browser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-2.1.1.tgz", + "integrity": "sha512-jizu1+2PAUjiGIfRtlPEU8Yo6zn+d78ti/ZHDesdf1SUn2BuZW433JlPoCOLH3dBoEEvTgLvQ8tUGSoTTALA+A==", + "dependencies": { + "@smithy/chunked-blob-reader": "^2.1.1", + "@smithy/chunked-blob-reader-native": "^2.1.1", + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-2.1.1.tgz", + "integrity": "sha512-Qhoq0N8f2OtCnvUpCf+g1vSyhYQrZjhSwvJ9qvR8BUGOtTXiyv2x1OD2e6jVGmlpC4E4ax1USHoyGfV9JFsACg==", + "dependencies": { + "@smithy/types": "^2.9.1", + "@smithy/util-buffer-from": "^2.1.1", + "@smithy/util-utf8": "^2.1.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/hash-stream-node": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-2.1.1.tgz", + "integrity": "sha512-VgDaKcfCy0iHcmtAZgZ3Yw9g37Gkn2JsQiMtFQXUh8Wmo3GfNgDwLOtdhJ272pOT7DStzpe9cNr+eV5Au8KfQA==", + "dependencies": { + "@smithy/types": "^2.9.1", + "@smithy/util-utf8": "^2.1.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-2.1.1.tgz", + "integrity": "sha512-7WTgnKw+VPg8fxu2v9AlNOQ5yaz6RA54zOVB4f6vQuR0xFKd+RzlCpt0WidYTsye7F+FYDIaS/RnJW4pxjNInw==", + "dependencies": { + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.1.1.tgz", + "integrity": "sha512-xozSQrcUinPpNPNPds4S7z/FakDTh1MZWtRP/2vQtYB/u3HYrX2UXuZs+VhaKBd6Vc7g2XPr2ZtwGBNDN6fNKQ==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/md5-js": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-2.1.1.tgz", + "integrity": "sha512-L3MbIYBIdLlT+MWTYrdVSv/dow1+6iZ1Ad7xS0OHxTTs17d753ZcpOV4Ro7M7tRAVWML/sg2IAp/zzCb6aAttg==", + "dependencies": { + "@smithy/types": "^2.9.1", + "@smithy/util-utf8": "^2.1.1", + "tslib": "^2.5.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-2.1.1.tgz", + "integrity": "sha512-rSr9ezUl9qMgiJR0UVtVOGEZElMdGFyl8FzWEF5iEKTlcWxGr2wTqGfDwtH3LAB7h+FPkxqv4ZU4cpuCN9Kf/g==", + "dependencies": { + "@smithy/protocol-http": "^3.1.1", + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-2.4.1.tgz", + "integrity": "sha512-XPZTb1E2Oav60Ven3n2PFx+rX9EDsU/jSTA8VDamt7FXks67ekjPY/XrmmPDQaFJOTUHJNKjd8+kZxVO5Ael4Q==", + "dependencies": { + "@smithy/middleware-serde": "^2.1.1", + "@smithy/node-config-provider": "^2.2.1", + "@smithy/shared-ini-file-loader": "^2.3.1", + "@smithy/types": "^2.9.1", + "@smithy/url-parser": "^2.1.1", + "@smithy/util-middleware": "^2.1.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-2.1.1.tgz", + "integrity": "sha512-eMIHOBTXro6JZ+WWzZWd/8fS8ht5nS5KDQjzhNMHNRcG5FkNTqcKpYhw7TETMYzbLfhO5FYghHy1vqDWM4FLDA==", + "dependencies": { + "@smithy/node-config-provider": "^2.2.1", + "@smithy/protocol-http": "^3.1.1", + "@smithy/service-error-classification": "^2.1.1", + "@smithy/smithy-client": "^2.3.1", + "@smithy/types": "^2.9.1", + "@smithy/util-middleware": "^2.1.1", + "@smithy/util-retry": "^2.1.1", + "tslib": "^2.5.0", + "uuid": "^8.3.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/middleware-retry/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-2.1.1.tgz", + "integrity": "sha512-D8Gq0aQBeE1pxf3cjWVkRr2W54t+cdM2zx78tNrVhqrDykRA7asq8yVJij1u5NDtKzKqzBSPYh7iW0svUKg76g==", + "dependencies": { + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-2.1.1.tgz", + "integrity": "sha512-KPJhRlhsl8CjgGXK/DoDcrFGfAqoqvuwlbxy+uOO4g2Azn1dhH+GVfC3RAp+6PoL5PWPb+vt6Z23FP+Mr6qeCw==", + "dependencies": { + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-2.2.1.tgz", + "integrity": "sha512-epzK3x1xNxA9oJgHQ5nz+2j6DsJKdHfieb+YgJ7ATWxzNcB7Hc+Uya2TUck5MicOPhDV8HZImND7ZOecVr+OWg==", + "dependencies": { + "@smithy/property-provider": "^2.1.1", + "@smithy/shared-ini-file-loader": "^2.3.1", + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-2.3.1.tgz", + "integrity": "sha512-gLA8qK2nL9J0Rk/WEZSvgin4AppvuCYRYg61dcUo/uKxvMZsMInL5I5ZdJTogOvdfVug3N2dgI5ffcUfS4S9PA==", + "dependencies": { + "@smithy/abort-controller": "^2.1.1", + "@smithy/protocol-http": "^3.1.1", + "@smithy/querystring-builder": "^2.1.1", + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-2.1.1.tgz", + "integrity": "sha512-FX7JhhD/o5HwSwg6GLK9zxrMUrGnb3PzNBrcthqHKBc3dH0UfgEAU24xnJ8F0uow5mj17UeBEOI6o3CF2k7Mhw==", + "dependencies": { + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-3.1.1.tgz", + "integrity": "sha512-6ZRTSsaXuSL9++qEwH851hJjUA0OgXdQFCs+VDw4tGH256jQ3TjYY/i34N4vd24RV3nrjNsgd1yhb57uMoKbzQ==", + "dependencies": { + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-2.1.1.tgz", + "integrity": "sha512-C/ko/CeEa8jdYE4gt6nHO5XDrlSJ3vdCG0ZAc6nD5ZIE7LBp0jCx4qoqp7eoutBu7VrGMXERSRoPqwi1WjCPbg==", + "dependencies": { + "@smithy/types": "^2.9.1", + "@smithy/util-uri-escape": "^2.1.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-2.1.1.tgz", + "integrity": "sha512-H4+6jKGVhG1W4CIxfBaSsbm98lOO88tpDWmZLgkJpt8Zkk/+uG0FmmqMuCAc3HNM2ZDV+JbErxr0l5BcuIf/XQ==", + "dependencies": { + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-2.1.1.tgz", + "integrity": "sha512-txEdZxPUgM1PwGvDvHzqhXisrc5LlRWYCf2yyHfvITWioAKat7srQvpjMAvgzf0t6t7j8yHrryXU9xt7RZqFpw==", + "dependencies": { + "@smithy/types": "^2.9.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-2.3.1.tgz", + "integrity": "sha512-2E2kh24igmIznHLB6H05Na4OgIEilRu0oQpYXo3LCNRrawHAcfDKq9004zJs+sAMt2X5AbY87CUCJ7IpqpSgdw==", + "dependencies": { + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-2.1.1.tgz", + "integrity": "sha512-Hb7xub0NHuvvQD3YwDSdanBmYukoEkhqBjqoxo+bSdC0ryV9cTfgmNjuAQhTPYB6yeU7hTR+sPRiFMlxqv6kmg==", + "dependencies": { + "@smithy/eventstream-codec": "^2.1.1", + "@smithy/is-array-buffer": "^2.1.1", + "@smithy/types": "^2.9.1", + "@smithy/util-hex-encoding": "^2.1.1", + "@smithy/util-middleware": "^2.1.1", + "@smithy/util-uri-escape": "^2.1.1", + "@smithy/util-utf8": "^2.1.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-2.3.1.tgz", + "integrity": "sha512-YsTdU8xVD64r2pLEwmltrNvZV6XIAC50LN6ivDopdt+YiF/jGH6PY9zUOu0CXD/d8GMB8gbhnpPsdrjAXHS9QA==", + "dependencies": { + "@smithy/middleware-endpoint": "^2.4.1", + "@smithy/middleware-stack": "^2.1.1", + "@smithy/protocol-http": "^3.1.1", + "@smithy/types": "^2.9.1", + "@smithy/util-stream": "^2.1.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-2.9.1.tgz", + "integrity": "sha512-vjXlKNXyprDYDuJ7UW5iobdmyDm6g8dDG+BFUncAg/3XJaN45Gy5RWWWUVgrzIK7S4R1KWgIX5LeJcfvSI24bw==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-2.1.1.tgz", + "integrity": "sha512-qC9Bv8f/vvFIEkHsiNrUKYNl8uKQnn4BdhXl7VzQRP774AwIjiSMMwkbT+L7Fk8W8rzYVifzJNYxv1HwvfBo3Q==", + "dependencies": { + "@smithy/querystring-parser": "^2.1.1", + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-2.1.1.tgz", + "integrity": "sha512-UfHVpY7qfF/MrgndI5PexSKVTxSZIdz9InghTFa49QOvuu9I52zLPLUHXvHpNuMb1iD2vmc6R+zbv/bdMipR/g==", + "dependencies": { + "@smithy/util-buffer-from": "^2.1.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-2.1.1.tgz", + "integrity": "sha512-ekOGBLvs1VS2d1zM2ER4JEeBWAvIOUKeaFch29UjjJsxmZ/f0L3K3x0dEETgh3Q9bkZNHgT+rkdl/J/VUqSRag==", + "dependencies": { + "tslib": "^2.5.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-2.2.1.tgz", + "integrity": "sha512-/ggJG+ta3IDtpNVq4ktmEUtOkH1LW64RHB5B0hcr5ZaWBmo96UX2cIOVbjCqqDickTXqBWZ4ZO0APuaPrD7Abg==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.1.1.tgz", + "integrity": "sha512-clhNjbyfqIv9Md2Mg6FffGVrJxw7bgK7s3Iax36xnfVj6cg0fUG7I4RH0XgXJF8bxi+saY5HR21g2UPKSxVCXg==", + "dependencies": { + "@smithy/is-array-buffer": "^2.1.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-2.2.1.tgz", + "integrity": "sha512-50VL/tx9oYYcjJn/qKqNy7sCtpD0+s8XEBamIFo4mFFTclKMNp+rsnymD796uybjiIquB7VCB/DeafduL0y2kw==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-2.1.1.tgz", + "integrity": "sha512-lqLz/9aWRO6mosnXkArtRuQqqZBhNpgI65YDpww4rVQBuUT7qzKbDLG5AmnQTCiU4rOquaZO/Kt0J7q9Uic7MA==", + "dependencies": { + "@smithy/property-provider": "^2.1.1", + "@smithy/smithy-client": "^2.3.1", + "@smithy/types": "^2.9.1", + "bowser": "^2.11.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-2.1.1.tgz", + "integrity": "sha512-tYVrc+w+jSBfBd267KDnvSGOh4NMz+wVH7v4CClDbkdPfnjvImBZsOURncT5jsFwR9KCuDyPoSZq4Pa6+eCUrA==", + "dependencies": { + "@smithy/config-resolver": "^2.1.1", + "@smithy/credential-provider-imds": "^2.2.1", + "@smithy/node-config-provider": "^2.2.1", + "@smithy/property-provider": "^2.1.1", + "@smithy/smithy-client": "^2.3.1", + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-1.1.1.tgz", + "integrity": "sha512-sI4d9rjoaekSGEtq3xSb2nMjHMx8QXcz2cexnVyRWsy4yQ9z3kbDpX+7fN0jnbdOp0b3KSTZJZ2Yb92JWSanLw==", + "dependencies": { + "@smithy/node-config-provider": "^2.2.1", + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-2.1.1.tgz", + "integrity": "sha512-3UNdP2pkYUUBGEXzQI9ODTDK+Tcu1BlCyDBaRHwyxhA+8xLP8agEKQq4MGmpjqb4VQAjq9TwlCQX0kP6XDKYLg==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-2.1.1.tgz", + "integrity": "sha512-mKNrk8oz5zqkNcbcgAAepeJbmfUW6ogrT2Z2gDbIUzVzNAHKJQTYmH9jcy0jbWb+m7ubrvXKb6uMjkSgAqqsFA==", + "dependencies": { + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-2.1.1.tgz", + "integrity": "sha512-Mg+xxWPTeSPrthpC5WAamJ6PW4Kbo01Fm7lWM1jmGRvmrRdsd3192Gz2fBXAMURyXpaNxyZf6Hr/nQ4q70oVEA==", + "dependencies": { + "@smithy/service-error-classification": "^2.1.1", + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-2.1.1.tgz", + "integrity": "sha512-J7SMIpUYvU4DQN55KmBtvaMc7NM3CZ2iWICdcgaovtLzseVhAqFRYqloT3mh0esrFw+3VEK6nQFteFsTqZSECQ==", + "dependencies": { + "@smithy/fetch-http-handler": "^2.4.1", + "@smithy/node-http-handler": "^2.3.1", + "@smithy/types": "^2.9.1", + "@smithy/util-base64": "^2.1.1", + "@smithy/util-buffer-from": "^2.1.1", + "@smithy/util-hex-encoding": "^2.1.1", + "@smithy/util-utf8": "^2.1.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-2.1.1.tgz", + "integrity": "sha512-saVzI1h6iRBUVSqtnlOnc9ssU09ypo7n+shdQ8hBTZno/9rZ3AuRYvoHInV57VF7Qn7B+pFJG7qTzFiHxWlWBw==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.1.1.tgz", + "integrity": "sha512-BqTpzYEcUMDwAKr7/mVRUtHDhs6ZoXDi9NypMvMfOr/+u1NW7JgqodPDECiiLboEm6bobcPcECxzjtQh865e9A==", + "dependencies": { + "@smithy/util-buffer-from": "^2.1.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-waiter": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-2.1.1.tgz", + "integrity": "sha512-kYy6BLJJNif+uqNENtJqWdXcpqo1LS+nj1AfXcDhOpqpSHJSAkVySLyZV9fkmuVO21lzGoxjvd1imGGJHph/IA==", "dependencies": { - "@sinonjs/commons": "^3.0.0" + "@smithy/abort-controller": "^2.1.1", + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" } }, "node_modules/@sqltools/formatter": { @@ -2193,6 +3796,12 @@ "@types/node": "*" } }, + "node_modules/@types/md5": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@types/md5/-/md5-2.3.5.tgz", + "integrity": "sha512-/i42wjYNgE6wf0j2bcTX6kuowmdL/6PE4IVitMpm2eYKBUuYCprdcWVK+xEF0gcV6ufMCRhtxmReGfc6hIK7Jw==", + "dev": true + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -2205,6 +3814,26 @@ "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "dev": true }, + "node_modules/@types/multer": { + "version": "1.4.11", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.11.tgz", + "integrity": "sha512-svK240gr6LVWvv3YGyhLlA+6LRRWA4mnGIU7RcNmgjBYFl6665wcXrRfxGp5tEPVHUNm5FMcmq7too9bxCwX/w==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/multer-s3": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/multer-s3/-/multer-s3-3.0.3.tgz", + "integrity": "sha512-VgWygI9UwyS7loLithUUi0qAMIDWdNrERS2Sb06UuPYiLzKuIFn2NgL7satyl4v8sh/LLoU7DiPanvbQaRg9Yg==", + "dev": true, + "dependencies": { + "@aws-sdk/client-s3": "^3.0.0", + "@types/multer": "*", + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "20.10.8", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.8.tgz", @@ -2214,6 +3843,16 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.11", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.11.tgz", + "integrity": "sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g==", + "dev": true, + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.0" + } + }, "node_modules/@types/qs": { "version": "6.9.11", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.11.tgz", @@ -2279,6 +3918,17 @@ "@types/superagent": "*" } }, + "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==", + "dev": true + }, + "node_modules/@types/validator": { + "version": "13.11.8", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.11.8.tgz", + "integrity": "sha512-c/hzNDBh7eRF+KbCf+OoZxKbnkpaK/cKp9iLQWqB7muXtM+MtL9SUUH8vCFcLn6dH1Qm05jiexK0ofWY7TfOhQ==" + }, "node_modules/@types/yargs": { "version": "17.0.32", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", @@ -2886,8 +4536,7 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/array-flatten": { "version": "1.1.1", @@ -2921,6 +4570,68 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "dev": true }, + "node_modules/available-typed-arrays": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.6.tgz", + "integrity": "sha512-j1QzY8iPNPG4o4xmO3ptzpRxTciqD3MgEHtifP/YnJpIo58Xu+ne4BejlbkuaLfXn/nz6HFiw29bLpj2PNMdGg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/aws-sdk": { + "version": "2.1550.0", + "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1550.0.tgz", + "integrity": "sha512-abkbOeaL7iV085UqO8Y7/Ep7VYONK32chhKejhMbPYSqTp2YgNeqOSQfSaVZWeWCwqJxujEyoXFGTNgTt46D0g==", + "dependencies": { + "buffer": "4.9.2", + "events": "1.1.1", + "ieee754": "1.1.13", + "jmespath": "0.16.0", + "querystring": "0.2.0", + "sax": "1.2.1", + "url": "0.10.3", + "util": "^0.12.4", + "uuid": "8.0.0", + "xml2js": "0.6.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/aws-sdk/node_modules/buffer": { + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", + "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", + "dependencies": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4", + "isarray": "^1.0.0" + } + }, + "node_modules/aws-sdk/node_modules/events": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", + "integrity": "sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw==", + "engines": { + "node": ">=0.4.x" + } + }, + "node_modules/aws-sdk/node_modules/ieee754": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", + "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==" + }, + "node_modules/aws-sdk/node_modules/uuid": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.0.0.tgz", + "integrity": "sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -3144,6 +4855,11 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/bowser": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", + "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==" + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -3352,6 +5068,14 @@ "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", "dev": true }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "engines": { + "node": "*" + } + }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -3417,6 +5141,21 @@ "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", "dev": true }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==" + }, + "node_modules/class-validator": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.1.tgz", + "integrity": "sha512-2VEG9JICxIqTpoK1eMzZqaV+u/EiwEJkMGzTrZf6sU/fwsnOITVgYJ8yojSy6CaXtO9V0Cc6ZQZ8h8m4UBuLwQ==", + "dependencies": { + "@types/validator": "^13.11.8", + "libphonenumber-js": "^1.10.53", + "validator": "^13.9.0" + } + }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -3816,6 +5555,23 @@ "node": ">= 8" } }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "engines": { + "node": "*" + } + }, + "node_modules/date-fns": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.3.1.tgz", + "integrity": "sha512-y8e109LYGgoQDveiEBD3DYXKba1jWf5BA8YU1FL5Tvm0BTdEfy54WLCwnuYWZNnzzvALy/QQ4Hov+Q9RVRv+Zw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/dayjs": { "version": "1.11.10", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", @@ -4394,7 +6150,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, "engines": { "node": ">=0.8.x" } @@ -4608,6 +6363,27 @@ "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" }, + "node_modules/fast-xml-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.2.5.tgz", + "integrity": "sha512-B9/wizE4WngqQftFPmdaMYlXoJlJOYxGQOanC77fq9k8+Z0v5dDSVh+3glErdIROP//s/jgb7ZuxKfB8nVyo0g==", + "funding": [ + { + "type": "paypal", + "url": "https://paypal.me/naturalintelligence" + }, + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "dependencies": { + "strnum": "^1.0.5" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastq": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.16.0.tgz", @@ -4662,6 +6438,14 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-type": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", + "integrity": "sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -4775,6 +6559,14 @@ "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", "dev": true }, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dependencies": { + "is-callable": "^1.1.3" + } + }, "node_modules/foreground-child": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", @@ -5190,6 +6982,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-unicode": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", @@ -5223,6 +7029,11 @@ "node": "*" } }, + "node_modules/html-comment-regex": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/html-comment-regex/-/html-comment-regex-1.1.2.tgz", + "integrity": "sha512-P+M65QY2JQ5Y0G9KKdlDpo0zK+/OHptU5AaBwUfAIDJZk1MYf32Frm84EcOytfJE0t5JvkAnKlmjsXDnWzCJmQ==" + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -5405,6 +7216,21 @@ "node": ">= 0.10" } }, + "node_modules/is-arguments": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", + "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -5423,6 +7249,22 @@ "node": ">=8" } }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-core-module": { "version": "2.13.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", @@ -5461,6 +7303,20 @@ "node": ">=6" } }, + "node_modules/is-generator-function": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -5517,6 +7373,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-typed-array": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", + "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", + "dependencies": { + "which-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -6265,6 +8135,14 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jmespath": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz", + "integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6275,7 +8153,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "dependencies": { "argparse": "^2.0.1" }, @@ -6429,6 +8306,11 @@ "node": ">= 0.8.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.10.54", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.10.54.tgz", + "integrity": "sha512-P+38dUgJsmh0gzoRDoM4F5jLbyfztkU6PY6eSK6S5HwTi/LPvnwXqVCQZlAy1FxZ5c48q25QhxGQ0pq+WQcSlQ==" + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -6583,6 +8465,16 @@ "tmpl": "1.0.5" } }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -6767,9 +8659,9 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/multer": { - "version": "1.4.4-lts.1", - "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.4-lts.1.tgz", - "integrity": "sha512-WeSGziVj6+Z2/MwQo3GvqzgR+9Uc+qt8SwHKh3gvNPiISKfsMfG4SvCOFYlxxgkXt7yIV2i1yczehm0EOKIxIg==", + "version": "1.4.5-lts.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.1.tgz", + "integrity": "sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==", "dependencies": { "append-field": "^1.0.0", "busboy": "^1.0.0", @@ -6783,6 +8675,23 @@ "node": ">= 6.0.0" } }, + "node_modules/multer-s3": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/multer-s3/-/multer-s3-3.0.1.tgz", + "integrity": "sha512-BFwSO80a5EW4GJRBdUuSHblz2jhVSAze33ZbnGpcfEicoT0iRolx4kWR+AJV07THFRCQ78g+kelKFdjkCCaXeQ==", + "dependencies": { + "@aws-sdk/lib-storage": "^3.46.0", + "file-type": "^3.3.0", + "html-comment-regex": "^1.1.2", + "run-parallel": "^1.1.6" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-s3": "^3.0.0" + } + }, "node_modules/mute-stream": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", @@ -6790,9 +8699,9 @@ "dev": true }, "node_modules/mysql2": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.7.0.tgz", - "integrity": "sha512-c45jA3Jc1X8yJKzrWu1GpplBKGwv/wIV6ITZTlCSY7npF2YfJR+6nMP5e+NTQhUeJPSyOQAbGDCGEHbAl8HN9w==", + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.9.1.tgz", + "integrity": "sha512-3njoWAAhGBYy0tWBabqUQcLtczZUxrmmtc2vszQUekg3kTJyZ5/IeLC3Fo04u6y6Iy5Sba7pIIa2P/gs8D3ZeQ==", "dependencies": { "denque": "^2.1.0", "generate-function": "^2.3.1", @@ -7172,6 +9081,33 @@ "node": ">= 0.8" } }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "peer": true, + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "peer": true, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -7240,6 +9176,12 @@ "node": ">=8" } }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==", + "peer": true + }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -7471,11 +9413,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystring": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", + "integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==", + "deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.", + "engines": { + "node": ">=0.4.x" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, "funding": [ { "type": "github", @@ -7781,7 +9731,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, "funding": [ { "type": "github", @@ -7832,6 +9781,11 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/sax": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", + "integrity": "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==" + }, "node_modules/schema-utils": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", @@ -8182,6 +10136,28 @@ "node": ">= 0.8" } }, + "node_modules/stream-browserify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", + "integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==", + "dependencies": { + "inherits": "~2.0.4", + "readable-stream": "^3.5.0" + } + }, + "node_modules/stream-browserify/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -8296,6 +10272,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", + "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==" + }, "node_modules/superagent": { "version": "8.1.2", "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz", @@ -8365,6 +10346,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swagger-ui-dist": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.11.0.tgz", + "integrity": "sha512-j0PIATqQSEFGOLmiJOJZj1X1Jt6bFIur3JpY7+ghliUnfZs0fpWDdHEkn9q7QUlBtKbkn6TepvSxTqnE8l3s0A==" + }, "node_modules/symbol-observable": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", @@ -8856,9 +10842,9 @@ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" }, "node_modules/typeorm": { - "version": "0.3.19", - "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.19.tgz", - "integrity": "sha512-OGelrY5qEoAU80mR1iyvmUHiKCPUydL6xp6bebXzS7jyv/X70Gp/jBWRAfF4qGOfy2A7orMiGRfwsBUNbEL65g==", + "version": "0.3.20", + "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.20.tgz", + "integrity": "sha512-sJ0T08dV5eoZroaq9uPKBoNcGslHBR4E4y+EBHs//SiGbblGe7IeduP/IH4ddCcj0qp3PHwDwGnuvqEAnKlq/Q==", "dependencies": { "@sqltools/formatter": "^1.2.5", "app-root-path": "^3.1.0", @@ -8870,7 +10856,7 @@ "dotenv": "^16.0.3", "glob": "^10.3.10", "mkdirp": "^2.1.3", - "reflect-metadata": "^0.1.13", + "reflect-metadata": "^0.2.1", "sha.js": "^2.4.11", "tslib": "^2.5.0", "uuid": "^9.0.0", @@ -8882,7 +10868,7 @@ "typeorm-ts-node-esm": "cli-ts-node-esm.js" }, "engines": { - "node": ">= 12.9.0" + "node": ">=16.13.0" }, "funding": { "url": "https://opencollective.com/typeorm" @@ -8997,6 +10983,11 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/typeorm/node_modules/reflect-metadata": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.1.tgz", + "integrity": "sha512-i5lLI6iw9AU3Uu4szRNPPEkomnkjRTaVt9hy/bn5g/oSzekBSMeLZblcjP74AW0vBabqERLLIrz+gR8QYR54Tw==" + }, "node_modules/typescript": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", @@ -9083,6 +11074,32 @@ "punycode": "^2.1.0" } }, + "node_modules/url": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", + "integrity": "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ==", + "dependencies": { + "punycode": "1.3.2", + "querystring": "0.2.0" + } + }, + "node_modules/url/node_modules/punycode": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", + "integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==" + }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -9097,9 +11114,13 @@ } }, "node_modules/uuid": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", - "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==", + "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" + ], "bin": { "uuid": "dist/bin/uuid" } @@ -9124,6 +11145,14 @@ "node": ">=10.12.0" } }, + "node_modules/validator": { + "version": "13.11.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.11.0.tgz", + "integrity": "sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -9256,6 +11285,24 @@ "node": ">= 8" } }, + "node_modules/which-typed-array": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.14.tgz", + "integrity": "sha512-VnXFiIW8yNn9kIHN88xvZ4yOWchftKDsRJ8fEPacX/wl1lOvBrhsJ/OeJCXq7B0AaijRuqgzSKalJoPk+D8MPg==", + "dependencies": { + "available-typed-arrays": "^1.0.6", + "call-bind": "^1.0.5", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", @@ -9319,6 +11366,26 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "engines": { + "node": ">=4.0" + } + }, "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 6276248..6e43fd4 100644 --- a/package.json +++ b/package.json @@ -20,17 +20,31 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { - "@nestjs/common": "^10.0.0", + "@aws-sdk/client-s3": "^3.504.0", + "@aws-sdk/s3-request-presigner": "^3.504.0", + "@nestjs/common": "^10.3.1", "@nestjs/config": "^3.1.1", - "@nestjs/core": "^10.0.0", + "@nestjs/core": "^10.3.1", "@nestjs/mapped-types": "*", + "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.0.0", + "@nestjs/swagger": "^7.2.0", + "@nestjs/typeorm": "^10.0.1", + "aws-sdk": "^2.1550.0", "bcrypt": "^5.1.1", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "date-fns": "^3.3.1", "jsonwebtoken": "^9.0.2", - "mysql2": "^3.7.0", + "md5": "^2.3.0", + "multer": "^1.4.5-lts.1", + "multer-s3": "^3.0.1", + "mysql2": "^3.9.0", + "node-fetch": "^2.7.0", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", - "typeorm": "^0.3.19" + "typeorm": "^0.3.20", + "uuid": "^9.0.1" }, "devDependencies": { "@nestjs/cli": "^10.0.0", @@ -40,8 +54,12 @@ "@types/express": "^4.17.17", "@types/jest": "^29.5.2", "@types/jsonwebtoken": "^9.0.5", + "@types/md5": "^2.3.5", + "@types/multer-s3": "^3.0.3", "@types/node": "^20.3.1", + "@types/node-fetch": "^2.6.11", "@types/supertest": "^2.0.12", + "@types/uuid": "^9.0.8", "@typescript-eslint/eslint-plugin": "^5.59.11", "@typescript-eslint/parser": "^5.59.11", "eslint": "^8.42.0", diff --git a/src/app.controller.ts b/src/app.controller.ts index cce879e..24fa193 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -1,12 +1,4 @@ -import { Controller, Get } from '@nestjs/common'; -import { AppService } from './app.service'; +import { Controller } from '@nestjs/common'; @Controller() -export class AppController { - constructor(private readonly appService: AppService) {} - - @Get() - getHello(): string { - return this.appService.getHello(); - } -} +export class AppController {} diff --git a/src/app.module.ts b/src/app.module.ts index 9404dc9..9f42da9 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,3 +1,4 @@ +// app.module.ts import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; @@ -8,6 +9,17 @@ import { DiaryModule } from './diary/diary.module'; import { LocationModule } from './location/location.module'; import { ScheduleModule } from './schedule/schedule.module'; import { PlaceModule } from './place/place.module'; +import { JourneyModule } from './journey/journey.module'; +import { SignatureModule } from './signature/signature.module'; +import { RuleModule } from './rule/rule.module'; +import { CommentModule } from './comment/comment.module'; +import { DetailScheduleModule } from './detail-schedule/detail-schedule.module'; +import { S3Module } from './utils/S3.module'; +import { FollowModule } from './follow/follow.module'; +import { SearchModule } from './search/search.module'; +import { MapModule } from './map/map.module'; +import { MateModule } from './mate/mate.module'; +import { NotificationModule } from './notification/notification.module'; @Module({ imports: [ @@ -19,7 +31,18 @@ import { PlaceModule } from './place/place.module'; DiaryModule, LocationModule, ScheduleModule, + DetailScheduleModule, PlaceModule, + JourneyModule, + SignatureModule, + RuleModule, + CommentModule, + S3Module, + FollowModule, + SearchModule, + MapModule, + MateModule, + NotificationModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/app.service.ts b/src/app.service.ts index 927d7cc..7263d33 100644 --- a/src/app.service.ts +++ b/src/app.service.ts @@ -1,8 +1,4 @@ import { Injectable } from '@nestjs/common'; @Injectable() -export class AppService { - getHello(): string { - return 'Hello World!'; - } -} +export class AppService {} diff --git a/src/comment/comment.controller.ts b/src/comment/comment.controller.ts new file mode 100644 index 0000000..6aa42a2 --- /dev/null +++ b/src/comment/comment.controller.ts @@ -0,0 +1,115 @@ +import { + Controller, + Post, + Body, + Req, + UseGuards, + Param, + Patch, + Delete, +} from '@nestjs/common'; +import { CommentService } from './comment.service'; +import { CreateCommentDto } from './dto/create-comment.dto'; +import { ResponseCode } from '../response/response-code.enum'; +import { ResponseDto } from '../response/response.dto'; +import { UserGuard } from '../user/user.guard'; +import { Request } from 'express'; + +@Controller('mate/rule/detail/comment') +export class CommentController { + constructor(private readonly commentService: CommentService) {} + + // [1] 댓글 작성 + @Post('/:ruleId') + @UseGuards(UserGuard) + async createComment( + @Body() dto: CreateCommentDto, + @Param('ruleId') ruleId: number, + @Req() req: Request, + ): Promise> { + const result = await this.commentService.createComment( + dto, + ruleId, + req.user.id, + ); + + console.log('controller 진입'); + if (!result) { + return new ResponseDto( + ResponseCode.COMMENT_CREATION_FAIL, + false, + '여행 규칙 코멘트 생성 실패', + null, + ); + } else { + return new ResponseDto( + ResponseCode.COMMENT_CREATED, + true, + '여행 규칙 코멘트 생성 성공', + result, + ); + } + } + + // [2] 댓글 수정 + @Patch('/:ruleId/:commentId') + @UseGuards(UserGuard) + async updateComment( + @Body() dto: CreateCommentDto, + @Param('ruleId') ruleId: number, + @Param('commentId') commentId: number, + @Req() req: Request, + ): Promise> { + try { + const result = await this.commentService.updateComment( + dto, + ruleId, + req.user.id, + commentId, + ); + return new ResponseDto( + ResponseCode.COMMENT_UPDATE_SUCCESS, + true, + '여행 규칙 코멘트 수정 성공', + result, + ); + } catch (e) { + return new ResponseDto( + ResponseCode.COMMENT_UPDATE_FAIL, + false, + e.message, + null, + ); + } + } + + // [3] 댓글 삭제 + @Delete('/:ruleId/:commentId') + @UseGuards(UserGuard) + async deleteComment( + @Param('ruleId') ruleId: number, + @Param('commentId') commentId: number, + @Req() req: Request, + ): Promise> { + try { + const result = await this.commentService.deleteComment( + ruleId, + req.user.id, + commentId, + ); + return new ResponseDto( + ResponseCode.COMMENT_DELETE_SUCCESS, + true, + '여행 규칙 코멘트 삭제 성공', + result, + ); + } catch (e) { + return new ResponseDto( + ResponseCode.COMMENT_DELETE_FAIL, + false, + e.message, + null, + ); + } + } +} diff --git a/src/comment/comment.module.ts b/src/comment/comment.module.ts new file mode 100644 index 0000000..1520b96 --- /dev/null +++ b/src/comment/comment.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { CommentService } from './comment.service'; +import { CommentController } from './comment.controller'; + +@Module({ + controllers: [CommentController], + providers: [CommentService], +}) +export class CommentModule {} diff --git a/src/comment/comment.service.ts b/src/comment/comment.service.ts new file mode 100644 index 0000000..f3a2ba1 --- /dev/null +++ b/src/comment/comment.service.ts @@ -0,0 +1,129 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { CreateCommentDto } from './dto/create-comment.dto'; +import { CommentEntity } from './domain/comment.entity'; +import { RuleMainEntity } from '../rule/domain/rule.main.entity'; +import { UserEntity } from '../user/user.entity'; +import { NotificationEntity } from '../notification/notification.entity'; + +@Injectable() +export class CommentService { + // [1] 댓글 작성 + async createComment( + dto: CreateCommentDto, + ruleId: number, + userId: number, + ): Promise { + const comment = new CommentEntity(); + + const user = await UserEntity.findOneOrFail({ where: { id: userId } }); + const rule = await RuleMainEntity.findOneOrFail({ + where: { id: ruleId }, + relations: { invitations: { member: true } }, + }); + + if (!user || !rule) { + throw new Error('Data not found'); + } else { + console.log('user name: ' + user.name); + comment.user = user; + console.log('rule id: ' + rule.id); + comment.rule = rule; + comment.content = dto.content; + await comment.save(); + + // 댓글 알림 + for (const invitation of rule.invitations) { + const receiver = invitation.member; + if (receiver.id === user.id) { + continue; + } + + const notification = new NotificationEntity(); + notification.notificationReceiver = invitation.member; + notification.notificationSender = user; + notification.notificationTargetType = 'RULE'; + notification.notificationTargetId = rule.id; + notification.notificationTargetDesc = rule.mainTitle; + notification.notificationAction = 'COMMENT'; + await notification.save(); + } + } + return comment.id; + } + + // [2] 댓글 수정 + async updateComment( + dto: CreateCommentDto, + ruleId: number, + userId: number, + commentId: number, + ): Promise { + try { + // 사용자, 규칙, 댓글 검증 + const user = await UserEntity.findOne({ + where: { id: userId }, + }); + if (!user) throw new NotFoundException('사용자를 찾을 수 없습니다'); + const rule = await RuleMainEntity.findOne({ + where: { id: ruleId }, + }); + if (!rule) throw new NotFoundException('존재하지 않는 규칙입니다'); + const comment = await CommentEntity.findOne({ + where: { id: commentId }, + }); + if (!comment) throw new NotFoundException('존재하지 않는 댓글 입니다'); + + const checkValidateUser = await CommentEntity.findOne({ + where: { id: commentId, user: { id: userId }, rule: { id: ruleId } }, + }); + if (!!checkValidateUser) { + comment.content = dto.content; + await CommentEntity.save(comment); + return comment.id; + } else { + throw new NotFoundException('해당 댓글 수정 권한이 없는 사용자 입니다'); + } + } catch (e) { + console.log('검증 과정에서 에러 발생'); + throw new Error(e.message); + } + } + + // [3] 댓글 삭제 + async deleteComment( + ruleId: number, + userId: number, + commentId: number, + ): Promise { + try { + // 사용자, 규칙, 댓글 검증 + const user = await UserEntity.findOne({ + where: { id: userId }, + }); + if (!user) throw new NotFoundException('사용자를 찾을 수 없습니다'); + const rule = await RuleMainEntity.findOne({ + where: { id: ruleId }, + }); + if (!rule) throw new NotFoundException('존재하지 않는 규칙입니다'); + const comment = await CommentEntity.findOne({ + where: { id: commentId }, + }); + if (!comment) throw new NotFoundException('존재하지 않는 댓글 입니다'); + + // 해당 규칙에, 해당 사용자가 작성한, 해당 댓글 ID를 가진 댓글이 있는지 검증 + const checkValidateUser = await CommentEntity.findOne({ + where: { id: commentId, user: { id: userId }, rule: { id: ruleId } }, + }); + if (!!checkValidateUser) { + await comment.softRemove(); + console.log('댓글 삭제 성공'); + return comment.id; + } else { + throw new NotFoundException('해당 댓글 삭제 권한이 없는 사용자 입니다'); + } + } catch (e) { + console.log('검증 과정에서 에러 발생'); + throw new Error(e.message); + } + } +} diff --git a/src/comment/domain/comment.entity.ts b/src/comment/domain/comment.entity.ts new file mode 100644 index 0000000..800ed26 --- /dev/null +++ b/src/comment/domain/comment.entity.ts @@ -0,0 +1,39 @@ +import { + BaseEntity, + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, +} from 'typeorm'; +import { RuleMainEntity } from 'src/rule/domain/rule.main.entity'; +import { UserEntity } from 'src/user/user.entity'; + +@Entity() +export class CommentEntity extends BaseEntity { + @PrimaryGeneratedColumn({ type: 'bigint' }) + id: number; + + @Column({ type: 'varchar', length: 200 }) + content: string; + + @ManyToOne(() => RuleMainEntity, (ruleMain) => ruleMain.comments) + @JoinColumn({ name: 'rule_id' }) + rule: RuleMainEntity; + + @ManyToOne(() => UserEntity, (user) => user.comments) + @JoinColumn({ name: 'user_id' }) + user: UserEntity; + + @CreateDateColumn() + created: Date; + + @UpdateDateColumn() + updated: Date; + + @DeleteDateColumn() + deleted: Date; +} diff --git a/src/comment/dto/create-comment.dto.ts b/src/comment/dto/create-comment.dto.ts new file mode 100644 index 0000000..f9b0981 --- /dev/null +++ b/src/comment/dto/create-comment.dto.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class CreateCommentDto { + @IsNotEmpty() + @IsString() + content: string; +} diff --git a/src/database/database.module.ts b/src/database/database.module.ts index ff423a1..857bc80 100644 --- a/src/database/database.module.ts +++ b/src/database/database.module.ts @@ -5,4 +5,4 @@ import { databaseProviders } from './database.providers'; providers: [...databaseProviders], exports: [...databaseProviders], }) -export class DatabaseModule {} \ No newline at end of file +export class DatabaseModule {} diff --git a/src/database/database.providers.ts b/src/database/database.providers.ts index f732aa6..e7df9f8 100644 --- a/src/database/database.providers.ts +++ b/src/database/database.providers.ts @@ -12,7 +12,7 @@ export const databaseProviders = [ password: process.env.DB_PASS, database: process.env.DB_NAME, entities: [__dirname + '/../**/*.entity{.ts,.js}'], - synchronize: process.env.DB_SYNC.toString() === 'true', + synchronize: process.env.DB_SYNC === 'true', }); return dataSource.initialize(); diff --git a/src/detail-schedule/detail-schedule-info.dto.ts b/src/detail-schedule/detail-schedule-info.dto.ts new file mode 100644 index 0000000..eb2fae3 --- /dev/null +++ b/src/detail-schedule/detail-schedule-info.dto.ts @@ -0,0 +1,23 @@ +import { IsOptional, IsNumber, IsString, IsBoolean } from 'class-validator'; + +export class DetailScheduleInfoDto { + @IsNumber() + id: number; + + @IsOptional() + @IsString() + content: string; + + @IsOptional() + @IsBoolean() + isDone: boolean; + + @IsNumber() + schedule_id: number; +} + +export class DetailContentDto { + @IsOptional() + @IsString() + content: string; +} diff --git a/src/detail-schedule/detail-schedule.controller.ts b/src/detail-schedule/detail-schedule.controller.ts new file mode 100644 index 0000000..58516cb --- /dev/null +++ b/src/detail-schedule/detail-schedule.controller.ts @@ -0,0 +1,106 @@ +import { + Controller, + Post, + Put, + Patch, + Delete, + Param, + Body, + UseGuards, + Req, +} from '@nestjs/common'; +import { ApiOkResponse, ApiOperation } from '@nestjs/swagger'; +import { Request } from 'express'; +import { DetailScheduleService } from './detail-schedule.service'; +import { DetailContentDto } from './detail-schedule-info.dto'; +import { UserGuard } from 'src/user/user.guard'; + +@Controller('detail-schedule') +export class DetailScheduleController { + constructor(private readonly detailScheduleService: DetailScheduleService) {} + + //세부일정 추가하기 + @ApiOperation({ + summary: '세부 일정 추가하기', + description: '일정 배너에서 세부 일정을 추가할 수 있습니다.', + }) + @ApiOkResponse({ + description: '성공 ', + }) + @UseGuards(UserGuard) + @Post('create/:scheduleId') + async createDetailSchedule( + @Req() req: Request, + @Param('scheduleId') scheduleId: number, + @Body() body: DetailContentDto, + ) { + const result = await this.detailScheduleService.createDetailSchedule( + scheduleId, + body, + ); + return result; + } + + //세부 일정 작성하기 + @ApiOperation({ + summary: '세부 일정 작성하기', + description: '일정 배너에 세부 일정을 작성할 수 있습니다.', + }) + @ApiOkResponse({ + description: '성공 ', + }) + @UseGuards(UserGuard) + @Put('update/:detailId') + async updateDetailSchedule( + @Req() req: Request, + @Param('detailId') detailId: number, + @Body() detailContentDto: DetailContentDto, + ) { + const result = await this.detailScheduleService.updateDetailSchedule( + detailId, + detailContentDto.content, + ); + return result; + } + + //세부 일정 상태 업데이트 + @ApiOperation({ + summary: '세부 일정 상태 업데이트', + description: 'true면 false로, false면 true로', + }) + @ApiOkResponse({ + description: '성공 ', + }) + @UseGuards(UserGuard) + @Patch('update-status/:detailId') + async updateDetailStatus( + @Req() req: Request, + @Param('detailId') detailId: number, + ) { + const result = await this.detailScheduleService.updateDetailStatus( + detailId, + ); + return result; + } + + //세부 일정 삭제하기 + /*remove로 할지, softremove로 할지 고민 */ + @ApiOperation({ + summary: '세부 일정 삭제하기', + description: '일정 배너에 세부 일정을 삭제할 수 있습니다.', + }) + @ApiOkResponse({ + description: '성공 ', + }) + @UseGuards(UserGuard) + @Delete('delete/:detailId') + async deleteDetailSchedule( + @Req() req: Request, + @Param('detailId') detailId: number, + ) { + const result = await this.detailScheduleService.deleteDetailSchedule( + detailId, + ); + return result; + } +} diff --git a/src/detail-schedule/detail-schedule.entity.ts b/src/detail-schedule/detail-schedule.entity.ts new file mode 100644 index 0000000..44d7c38 --- /dev/null +++ b/src/detail-schedule/detail-schedule.entity.ts @@ -0,0 +1,80 @@ +import { + BaseEntity, + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { NotFoundException } from '@nestjs/common'; +import { BaseResponse } from 'src/response/response.status'; +import { ScheduleEntity } from '../schedule/schedule.entity'; +import { DetailContentDto } from './detail-schedule-info.dto'; + +@Entity() +export class DetailScheduleEntity extends BaseEntity { + @PrimaryGeneratedColumn() + id: number; + + @Column({ nullable: true }) + content: string; + + @Column({ default: false }) + isDone: boolean; + + @ManyToOne(() => ScheduleEntity, (schedule) => schedule.detailSchedules, { + onDelete: 'CASCADE', + }) + schedule: ScheduleEntity; + + @CreateDateColumn() + created: Date; + + @UpdateDateColumn() + updated: Date; + + @DeleteDateColumn() + deleted: Date; + + //세부 일정 추가하기 + static async createDetailSchedule(schedule, content: DetailContentDto) { + const detailSchedule = new DetailScheduleEntity(); + detailSchedule.schedule = schedule; + detailSchedule.content = content.content; + return await detailSchedule.save(); + } + //세부 일정 작성하기 + static async updateDetailSchedule(detailSchedule, content) { + detailSchedule.content = content; + return await detailSchedule.save(); + } + //세부 일정 상태 업데이트하기 + static async updateDetailStatus(detailSchedule) { + detailSchedule.isDone = !detailSchedule.isDone; + return await detailSchedule.save(); + } + //세부 일정 삭제하기 + static async deleteDetailSchedule(detailSchedule) { + return await DetailScheduleEntity.remove(detailSchedule); + } + + static async findExistDetail(detailId: number) { + const detail = await DetailScheduleEntity.findOne({ + where: { id: detailId }, + }); + if (!detail) { + throw new NotFoundException(BaseResponse.DETAIL_SCHEDULE_NOT_FOUND); + } + return detail; + } + + static async findExistDetailByScheduleId(schedule) { + const detail = await DetailScheduleEntity.find({ + where: { schedule: { id: schedule.id } }, + select: ['id', 'content', 'isDone'], + }); + return detail; + } +} diff --git a/src/detail-schedule/detail-schedule.module.ts b/src/detail-schedule/detail-schedule.module.ts new file mode 100644 index 0000000..c0b643e --- /dev/null +++ b/src/detail-schedule/detail-schedule.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { DetailScheduleService } from './detail-schedule.service'; +import { DetailScheduleController } from './detail-schedule.controller'; + +@Module({ + controllers: [DetailScheduleController], + providers: [DetailScheduleService], +}) +export class DetailScheduleModule {} diff --git a/src/detail-schedule/detail-schedule.service.ts b/src/detail-schedule/detail-schedule.service.ts new file mode 100644 index 0000000..241ff4e --- /dev/null +++ b/src/detail-schedule/detail-schedule.service.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@nestjs/common'; +import { ScheduleEntity } from 'src/schedule/schedule.entity'; +import { DetailScheduleEntity } from './detail-schedule.entity'; +import { response } from 'src/response/response'; +import { BaseResponse } from 'src/response/response.status'; +import { DetailContentDto } from './detail-schedule-info.dto'; + +@Injectable() +export class DetailScheduleService { + //세부일정 추가하기 + async createDetailSchedule(scheduleId: number, content: DetailContentDto) { + const schedule = await ScheduleEntity.findExistSchedule(scheduleId); + console.log(schedule.id); + await DetailScheduleEntity.createDetailSchedule(schedule, content); + return response(BaseResponse.DETAIL_SCHEDULE_CREATED); + } + + //세부일정 작성하기 + async updateDetailSchedule(detailId, content) { + const detailSchedule = await DetailScheduleEntity.findExistDetail(detailId); + const updateContent = await DetailScheduleEntity.updateDetailSchedule( + detailSchedule, + content, + ); + console.log(updateContent); + return response(BaseResponse.DETAIL_SCHEDULE_UPDATED); + } + + //세부일정 상태 업데이트하기 + async updateDetailStatus(detailId) { + const detailSchedule = await DetailScheduleEntity.findExistDetail(detailId); + const updateStatus = await DetailScheduleEntity.updateDetailStatus( + detailSchedule, + ); + console.log(updateStatus); + return response( + BaseResponse.UPDATE_DETAIL_SCHEDULE_STATUS_SUCCESS, + updateStatus.isDone, + ); + } + + //세부일정 삭제하기 + async deleteDetailSchedule(detailId: number) { + const detailSchedule = await DetailScheduleEntity.findExistDetail(detailId); + await DetailScheduleEntity.deleteDetailSchedule(detailSchedule); + return response(BaseResponse.DELETE_DETAIL_SCHEDULE_SUCCESS); + } +} diff --git a/src/diary/diary.controller.ts b/src/diary/diary.controller.ts index 3b42be4..b0e832d 100644 --- a/src/diary/diary.controller.ts +++ b/src/diary/diary.controller.ts @@ -1,7 +1,73 @@ -import { Controller } from '@nestjs/common'; +import { ApiOperation, ApiOkResponse } from '@nestjs/swagger'; +import { + Controller, + Req, + UseGuards, + Put, + Post, + Get, + Body, + Param, +} from '@nestjs/common'; +import { Request } from 'express'; +import { UserGuard } from 'src/user/user.guard'; import { DiaryService } from './diary.service'; +import { PostDiaryDto } from './dtos/post-diary.dto'; @Controller('diary') export class DiaryController { constructor(private readonly diaryService: DiaryService) {} + + /*일지 작성하기 */ + @ApiOperation({ + summary: '일지 작성하기', + description: '일지를 작성하고 저장한 상태', + }) + @ApiOkResponse({ + description: '성공 ', + }) + @UseGuards(UserGuard) + @Post('create/:scheduleId') + async postJourney( + @Req() req: Request, + @Param('scheduleId') scheduleId: number, + @Body() body: PostDiaryDto, + ) { + const result = await this.diaryService.createDiary(scheduleId, body); + return result; + } + + /*일지 수정하기 */ + @ApiOperation({ + summary: '일지 수정하기', + description: '일지를 작성 후 확인하기에서 바로 수정 가능', + }) + @ApiOkResponse({ + description: '성공 ', + }) + @UseGuards(UserGuard) + @Put('update/:diaryId') + async updateDiary( + @Req() req: Request, + @Param('diaryId') diaryId: number, + @Body() body: PostDiaryDto, + ) { + const result = await this.diaryService.updateDiary(diaryId, body); + return result; + } + + /*일지 불러오기-캘린더*/ + @ApiOperation({ + summary: '일지 불러오기 - 캘린더 ', + description: '일지가 존재하는 경우 id,date,내용', + }) + @ApiOkResponse({ + description: '성공 ', + }) + @UseGuards(UserGuard) + @Get('get/:scheduleId') + async getDiary(@Req() req: Request, @Param('scheduleId') scheduleId: number) { + const result = await this.diaryService.getDiary(scheduleId); + return result; + } } diff --git a/src/diary/diary.entity.ts b/src/diary/diary.entity.ts deleted file mode 100644 index 8e797e9..0000000 --- a/src/diary/diary.entity.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { - BaseEntity, - Column, - CreateDateColumn, - DeleteDateColumn, - Entity, - JoinColumn, - ManyToOne, OneToMany, - PrimaryGeneratedColumn, - UpdateDateColumn, -} from 'typeorm'; -import { UserEntity } from '../user/user.entity'; -import { DiaryImageEntity } from './diary.image.entity'; -import { LocationEntity } from '../location/location.entity'; - -@Entity() -export class DiaryEntity extends BaseEntity { - @PrimaryGeneratedColumn() - id: number; - - @JoinColumn() - @ManyToOne(() => UserEntity) - author: UserEntity; - - @Column() - title: string; - - @JoinColumn() - @ManyToOne(() => LocationEntity) - location: LocationEntity; - - @Column({ type: 'enum', enum: ['SUNNY', 'RAINY', 'SNOWY', 'CLOUDY'] }) - weather: 'SUNNY' | 'RAINY' | 'SNOWY' | 'CLOUDY'; - - @Column({ type: 'enum', enum: ['HAPPY', 'SAD', 'ANGRY', 'SHOCKED', 'SLEEPY', 'WINK'] }) - mood: 'HAPPY' | 'SAD' | 'ANGRY' | 'SHOCKED' | 'SLEEPY' | 'WINK'; - - @Column({ type: 'mediumtext' }) - detail: string; - - @OneToMany(() => DiaryImageEntity, (image) => image.diary) - images: DiaryImageEntity[]; - - @CreateDateColumn() - created: Date; - - @UpdateDateColumn() - updated: Date; - - @DeleteDateColumn() - deleted: Date; -} \ No newline at end of file diff --git a/src/diary/diary.image.entity.ts b/src/diary/diary.image.entity.ts deleted file mode 100644 index f57e444..0000000 --- a/src/diary/diary.image.entity.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { - BaseEntity, Column, - CreateDateColumn, - DeleteDateColumn, - Entity, - JoinColumn, - ManyToOne, - PrimaryGeneratedColumn, - UpdateDateColumn, -} from 'typeorm'; -import { DiaryEntity } from './diary.entity'; - -@Entity() -export class DiaryImageEntity extends BaseEntity { - @PrimaryGeneratedColumn() - id: number; - - @JoinColumn() - @ManyToOne(() => DiaryEntity, (diary) => diary.images) - diary: DiaryEntity; - - @Column() - imageKey: string; - - @CreateDateColumn() - created: Date; - - @UpdateDateColumn() - updated: Date; - - @DeleteDateColumn() - deleted: Date; -} \ No newline at end of file diff --git a/src/diary/diary.module.ts b/src/diary/diary.module.ts index 36441d7..82161b7 100644 --- a/src/diary/diary.module.ts +++ b/src/diary/diary.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { DiaryService } from './diary.service'; import { DiaryController } from './diary.controller'; +import { S3Module } from 'src/utils/S3.module'; @Module({ + imports: [S3Module], controllers: [DiaryController], providers: [DiaryService], }) diff --git a/src/diary/diary.service.ts b/src/diary/diary.service.ts index bd66641..bf83606 100644 --- a/src/diary/diary.service.ts +++ b/src/diary/diary.service.ts @@ -1,4 +1,67 @@ import { Injectable } from '@nestjs/common'; +import { response, errResponse } from 'src/response/response'; +import { BaseResponse } from 'src/response/response.status'; +import { DiaryEntity } from './models/diary.entity'; +import { DiaryImageEntity } from './models/diary.image.entity'; +import { PostDiaryDto } from './dtos/post-diary.dto'; +import { DiaryInfoDto } from './dtos/diary-info.dto'; +import { S3UtilService } from 'src/utils/S3.service'; +import { ScheduleEntity } from 'src/schedule/schedule.entity'; @Injectable() -export class DiaryService {} +export class DiaryService { + constructor(private readonly s3UtilService: S3UtilService) {} + + /*일지 작성하기*/ + async createDiary(scheduleId, diaryInfo: PostDiaryDto) { + const diary = await DiaryEntity.createDiary(scheduleId, diaryInfo); + const diaryImg = await this.getDiaryImgUrl(diary, diaryInfo.fileName); + await DiaryImageEntity.createDiaryImg(diary, diaryImg); + return response(BaseResponse.DIARY_CREATED); + } + + /*일지 수정하기*/ + async updateDiary(diaryId, diaryInfo: PostDiaryDto) { + const diary = await DiaryEntity.updateDiary(diaryId, diaryInfo); + if ( + diaryInfo.fileName.startsWith('https://hereyou-cdn.kaaang.dev/diary/') + ) { + return response(BaseResponse.DIARY_CREATED); + } else { + const diaryImg = await this.getDiaryImgUrl(diary, diaryInfo.fileName); + await DiaryImageEntity.updateDiaryImg(diary, diaryImg); + return response(BaseResponse.DIARY_CREATED); + } + } + + /*일지 사진 S3에 업로드 후 url 받기- 생성 */ + async getDiaryImgUrl(diary, fileName: string) { + const imageKey = `diary/${this.s3UtilService.generateRandomImageKey( + 'diary.png', + )}`; + await this.s3UtilService.putObjectFromBase64(imageKey, fileName); + return imageKey; + } + + /*캘린더에서 일지 불러오기*/ + async getDiary(scheduleId: number) { + const schedule = await ScheduleEntity.findExistSchedule(scheduleId); + const diary = await DiaryEntity.findExistDiaryByScheduleId(schedule); + if (!diary) { + return errResponse(BaseResponse.DIARY_NOT_FOUND); + } + const imageKey = await DiaryImageEntity.findExistImgUrl(diary); + const diaryImg = await this.s3UtilService.getImageUrl(imageKey.imageUrl); + const diaryInfo: DiaryInfoDto = new DiaryInfoDto(); + diaryInfo.id = diary.id; + diaryInfo.date = schedule.date; + diaryInfo.title = diary.title; + diaryInfo.place = diary.place; + diaryInfo.weather = diary.weather; + diaryInfo.mood = diary.mood; + diaryInfo.content = diary.content; + diaryInfo.imageId = diary.id; + diaryInfo.imageUrl = diaryImg; + return response(BaseResponse.GET_DIARY_SUCCESS, diaryInfo); + } +} diff --git a/src/diary/dtos/diary-info.dto.ts b/src/diary/dtos/diary-info.dto.ts new file mode 100644 index 0000000..863c11f --- /dev/null +++ b/src/diary/dtos/diary-info.dto.ts @@ -0,0 +1,30 @@ +import { IsString, IsEnum, IsDate, IsInt } from 'class-validator'; + +export class DiaryInfoDto { + @IsInt() + id: number; + + @IsDate() + date: Date; + + @IsString() + title: string; + + @IsString() + place: string; + + @IsEnum(['CLOUDY', 'RAINY', 'SNOWY', 'PARTLY_CLOUDY', 'SUNNY']) + weather: 'CLOUDY' | 'RAINY' | 'SNOWY' | 'PARTLY_CLOUDY' | 'SUNNY'; + + @IsEnum(['ANGRY', 'SAD', 'SMILE', 'HAPPY', 'SHOCKED']) + mood: 'ANGRY' | 'SAD' | 'SMILE' | 'HAPPY' | 'SHOCKED'; + + @IsString() + content: string; + + @IsInt() + imageId: number; + + @IsString() + imageUrl: string; +} diff --git a/src/diary/dtos/get-diary-img-url.dto.ts b/src/diary/dtos/get-diary-img-url.dto.ts new file mode 100644 index 0000000..68ee7df --- /dev/null +++ b/src/diary/dtos/get-diary-img-url.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class GetDiaryImgUrlDto { + @ApiProperty({ + example: 'abc.png', + description: '파일명', + required: true, + }) + @IsString() + @IsNotEmpty() + fileName: string; +} diff --git a/src/diary/dtos/post-diary.dto.ts b/src/diary/dtos/post-diary.dto.ts new file mode 100644 index 0000000..f6920af --- /dev/null +++ b/src/diary/dtos/post-diary.dto.ts @@ -0,0 +1,21 @@ +import { IsString, IsEnum } from 'class-validator'; + +export class PostDiaryDto { + @IsString() + title: string; + + @IsString() + place: string; + + @IsEnum(['CLOUDY', 'RAINY', 'SNOWY', 'PARTLY_CLOUDY', 'SUNNY']) + weather: 'CLOUDY' | 'RAINY' | 'SNOWY' | 'PARTLY_CLOUDY' | 'SUNNY'; + + @IsEnum(['ANGRY', 'SAD', 'SMILE', 'HAPPY', 'SHOCKED']) + mood: 'ANGRY' | 'SAD' | 'SMILE' | 'HAPPY' | 'SHOCKED'; + + @IsString() + content: string; + + @IsString() + fileName: string; +} diff --git a/src/diary/models/diary.entity.ts b/src/diary/models/diary.entity.ts new file mode 100644 index 0000000..af47e2b --- /dev/null +++ b/src/diary/models/diary.entity.ts @@ -0,0 +1,116 @@ +import { + BaseEntity, + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + JoinColumn, + ManyToOne, + OneToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { NotFoundException } from '@nestjs/common'; +import { BaseResponse } from 'src/response/response.status'; +import { ScheduleEntity } from 'src/schedule/schedule.entity'; +import { UserEntity } from '../../user/user.entity'; +import { DiaryImageEntity } from './diary.image.entity'; +import { PostDiaryDto } from '../dtos/post-diary.dto'; + +@Entity() +export class DiaryEntity extends BaseEntity { + @PrimaryGeneratedColumn() + id: number; + + @JoinColumn() + @ManyToOne(() => UserEntity) + author: UserEntity; + + @Column({ nullable: true }) + title: string; + + @Column({ nullable: true }) + place: string; + + @Column({ + type: 'enum', + enum: ['CLOUDY', 'RAINY', 'SNOWY', 'PARTLY_CLOUDY', 'SUNNY'], + nullable: true, + }) + weather: 'CLOUDY' | 'RAINY' | 'SNOWY' | 'PARTLY_CLOUDY' | 'SUNNY'; + + @Column({ + type: 'enum', + enum: ['ANGRY', 'SAD', 'SMILE', 'HAPPY', 'SHOCKED'], + nullable: true, + }) + mood: 'ANGRY' | 'SAD' | 'SMILE' | 'HAPPY' | 'SHOCKED'; + + @Column({ nullable: true, type: 'mediumtext' }) + content: string; + + @OneToOne(() => DiaryImageEntity, (image) => image.diary, {}) + image: DiaryImageEntity; + + @JoinColumn() + @OneToOne(() => ScheduleEntity, (schedule) => schedule.diary) + schedule: ScheduleEntity; + + @CreateDateColumn() + created: Date; + + @UpdateDateColumn() + updated: Date; + + @DeleteDateColumn() + deleted: Date; + + //일지 생성하기 + static async createDiary(scheduleId, diaryInfo) { + const diary = new DiaryEntity(); + diary.title = diaryInfo.title; + diary.place = diaryInfo.place; + diary.weather = diaryInfo.weather; + diary.mood = diaryInfo.mood; + diary.content = diaryInfo.content; + + diary.schedule = scheduleId; + + return await diary.save(); + } + //일지 수정하기 + static async updateDiary(diaryId, diaryInfo: PostDiaryDto) { + const diary = await this.findExistDiary(diaryId); + diary.title = diaryInfo.title; + diary.place = diaryInfo.place; + diary.weather = diaryInfo.weather; + diary.mood = diaryInfo.mood; + diary.content = diaryInfo.content; + + return await diary.save(); + } + + // 일지 삭제하기 + static async deleteDiary(diary) { + return await DiaryEntity.remove(diary); + } + + //일지 조회하기 + static async findExistDiary(diaryId) { + const diary = await DiaryEntity.findOne({ + where: { id: diaryId }, + }); + if (!diary) { + throw new NotFoundException(BaseResponse.DIARY_NOT_FOUND); + } + return diary; + } + + //scheduleId로 일지 조회하기 + static async findExistDiaryByScheduleId(schedule) { + const diary = await DiaryEntity.findOne({ + where: { schedule: { id: schedule.id } }, + }); + return diary; + } +} diff --git a/src/diary/models/diary.image.entity.ts b/src/diary/models/diary.image.entity.ts new file mode 100644 index 0000000..b39a49c --- /dev/null +++ b/src/diary/models/diary.image.entity.ts @@ -0,0 +1,68 @@ +import { + BaseEntity, + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + JoinColumn, + OneToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { DiaryEntity } from './diary.entity'; + +@Entity() +export class DiaryImageEntity extends BaseEntity { + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: 'mediumtext' }) + imageUrl: string; + + @JoinColumn() + @OneToOne(() => DiaryEntity, (diary) => diary.image, { + onDelete: 'CASCADE', + }) + diary: DiaryEntity; + + @CreateDateColumn() + created: Date; + + @UpdateDateColumn() + updated: Date; + + @DeleteDateColumn() + deleted: Date; + + //사진 URL 저장 + static async createDiaryImg(diary: DiaryEntity, ImgUrl: string) { + const diaryImg = new DiaryImageEntity(); + diaryImg.imageUrl = ImgUrl; + diaryImg.diary = diary; + console.log(diaryImg.diary); + await diaryImg.save(); + } + + //사진 URL 수정 + static async updateDiaryImg(diary: DiaryEntity, ImgUrl: string) { + const diaryImg = await DiaryImageEntity.findOne({ + where: { diary: { id: diary.id } }, + }); + diaryImg.imageUrl = ImgUrl; + diaryImg.diary = diary; + await diaryImg.save(); + } + + //사진 URL 불러오기 + static async findExistImgUrl(diary: DiaryEntity) { + const imgUrl = await DiaryImageEntity.findOne({ + where: { diary: { id: diary.id } }, + }); + return imgUrl; + } + + //세부 일정 삭제하기 + static async deleteDiaryImg(diaryImg) { + return await DiaryImageEntity.remove(diaryImg); + } +} diff --git a/src/follow/dto/follow.dto.ts b/src/follow/dto/follow.dto.ts new file mode 100644 index 0000000..82a206f --- /dev/null +++ b/src/follow/dto/follow.dto.ts @@ -0,0 +1,33 @@ +import { + IsBoolean, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, +} from 'class-validator'; + +export class FollowDto { + @IsNotEmpty() + @IsNumber() + mateId: number; + + @IsNotEmpty() + @IsString() + nickName: string; + + @IsNotEmpty() + @IsString() + email: string; + + @IsOptional() + @IsString() + image: string; + + @IsOptional() + @IsString() + introduction: string; + + @IsNotEmpty() + @IsBoolean() + isFollowing: boolean; +} diff --git a/src/follow/dto/follow.search.dto.ts b/src/follow/dto/follow.search.dto.ts new file mode 100644 index 0000000..108e28f --- /dev/null +++ b/src/follow/dto/follow.search.dto.ts @@ -0,0 +1,37 @@ +import { + IsBoolean, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, +} from 'class-validator'; + +export class FollowSearchDto { + @IsNotEmpty() + @IsNumber() + id: number; + + @IsNotEmpty() + @IsString() + nickName: string; + + @IsOptional() + @IsString() + introduction: string; + + @IsNotEmpty() + @IsString() + followerCnt: number; + + @IsOptional() + @IsString() + followingCnt: number; + + @IsOptional() + @IsString() + image: string; + + @IsOptional() + @IsBoolean() + isFollowing: boolean; +} diff --git a/src/follow/follow.controller.ts b/src/follow/follow.controller.ts new file mode 100644 index 0000000..923ddba --- /dev/null +++ b/src/follow/follow.controller.ts @@ -0,0 +1,129 @@ +import { + Controller, + Req, + UseGuards, + Param, + Get, + Patch, + Query, +} from '@nestjs/common'; +import { FollowService } from './follow.service'; +import { ResponseCode } from '../response/response-code.enum'; +import { ResponseDto } from '../response/response.dto'; +import { UserGuard } from '../user/user.guard'; +import { Request } from 'express'; +import { CursorPageOptionsDto } from '../rule/dto/cursor-page.options.dto'; + +@Controller('mate/follow') +export class FollowController { + constructor(private readonly followService: FollowService) {} + + // [1] 메이트 검색 - 무한 스크롤 적용 + @Get('/search') + @UseGuards(UserGuard) + async getSearchResult( + @Query('searchTerm') searchTerm: string, + @Query() cursorPageOptionsDto: CursorPageOptionsDto, + @Req() req: Request, + ): Promise> { + try { + const result = await this.followService.getSearchResult( + cursorPageOptionsDto, + req.user.id, + searchTerm, + ); + return new ResponseDto( + ResponseCode.GET_SEARCH_RESULT_SUCCESS, + true, + '검색 결과 리스트 불러오기 성공', + result, + ); + } catch (error) { + return new ResponseDto( + ResponseCode.GET_SEARCH_RESULT_FAIL, + false, + '검색 결과 리스트 불러오기 실패', + null, + ); + } + } + + // [2] 팔로워 리스트 조회 + @Get('/followerList') + @UseGuards(UserGuard) + async getFollowerList(@Req() req: Request): Promise> { + try { + const followerList = await this.followService.getFollowerList( + req.user.id, + ); + return new ResponseDto( + ResponseCode.GET_FOLLOWER_LIST_SUCCESS, + true, + '팔로워 리스트 불러오기 성공', + followerList, + ); + } catch (e) { + return new ResponseDto( + ResponseCode.GET_FOLLOWER_LIST_FAIL, + false, + e.message, + null, + ); + } + } + + // [3] 팔로우 리스트 조회 + @Get('/followList') + @UseGuards(UserGuard) + async getFollowList(@Req() req: Request): Promise> { + console.log('controller'); + try { + const followList = await this.followService.getFollowList(req.user.id); + return new ResponseDto( + ResponseCode.GET_FOLLOWING_LIST_SUCCESS, + true, + '팔로우 리스트 불러오기 성공', + followList, + ); + } catch (e) { + return new ResponseDto( + ResponseCode.GET_FOLLOWING_LIST_FAIL, + false, + e.message, + null, + ); + } + } + + // [4] 팔로우 + @Patch('/:followingId') + @UseGuards(UserGuard) + async createFollow( + @Req() req: Request, + @Param('followingId') followingId: number, + ): Promise> { + try { + const result = await this.followService.checkFollow( + req.user.id, + followingId, + ); + if (!!result.deleted) { + return new ResponseDto( + ResponseCode.FOLLOW_SUCCESS, + true, + '언팔로우 성공', + result.id, + ); + } else { + return new ResponseDto( + ResponseCode.FOLLOW_SUCCESS, + true, + '팔로우 성공', + result.id, + ); + } + } catch (e) { + return new ResponseDto(ResponseCode.FOLLOW_FAIL, false, e.message, null); + } + } +} diff --git a/src/follow/follow.module.ts b/src/follow/follow.module.ts new file mode 100644 index 0000000..dc4ba2d --- /dev/null +++ b/src/follow/follow.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { FollowService } from './follow.service'; +import { FollowController } from './follow.controller'; +import { UserService } from 'src/user/user.service'; +import { S3UtilService } from '../utils/S3.service'; + +@Module({ + controllers: [FollowController], + providers: [FollowService, UserService, S3UtilService], +}) +export class FollowModule {} diff --git a/src/follow/follow.service.ts b/src/follow/follow.service.ts new file mode 100644 index 0000000..afd4f5e --- /dev/null +++ b/src/follow/follow.service.ts @@ -0,0 +1,322 @@ +import { + Injectable, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; +import { UserFollowingEntity } from 'src/user/user.following.entity'; +import { FollowDto } from './dto/follow.dto'; +import { UserEntity } from '../user/user.entity'; +import { UserService } from '../user/user.service'; +import { S3UtilService } from '../utils/S3.service'; +import { LessThan, Like } from 'typeorm'; +import { FollowSearchDto } from './dto/follow.search.dto'; +import { CursorPageOptionsDto } from '../rule/dto/cursor-page.options.dto'; +import { CursorPageMetaDto } from '../rule/dto/cursor-page.meta.dto'; +import { CursorPageDto } from '../rule/dto/cursor-page.dto'; + +@Injectable() +export class FollowService { + constructor( + private readonly userService: UserService, + private readonly s3Service: S3UtilService, + ) {} + + // [1] 메이트 검색 + async getSearchResult( + cursorPageOptionsDto: CursorPageOptionsDto, + userId: number, + searchTerm: string, + ) { + try { + // 검증1) 사용자가 존재하지 않는 경우 + const userEntity = await UserEntity.findOne({ + where: { + id: userId, + }, + }); + if (!userEntity) throw new Error('사용자를 찾을 수 없습니다'); + + let cursorId = 0; + + // (1) cursorId 설정 + // -1) 처음 요청인 경우 + if (cursorPageOptionsDto.cursorId == 0) { + const newUser = await UserEntity.find({ + order: { + id: 'DESC', // 가장 최근에 가입한 유저 + }, + take: 1, + }); + cursorId = newUser[0].id + 1; + + console.log('cursorPageOptionsDto.cursorId == 0 로 인식'); + console.log('cursor: ', cursorId); + // -2) 처음 요청이 아닌 경우 + } else { + cursorId = cursorPageOptionsDto.cursorId; + console.log('cursorPageOptionsDto.cursorId != 0 로 인식'); + } + console.log('cursor: ', cursorId); + + // (2) 데이터 조회 + // 검색 결과에 해당하는 값 찾기 + // 해당 결과값을 nickName 에 포함하고 있는 사용자 찾기 + + console.log('검색 값: ', searchTerm); + + // take 초기값 설정 + console.log('cursorPageOptionsDto.take : ', cursorPageOptionsDto.take); + if (cursorPageOptionsDto.take == 0) { + cursorPageOptionsDto.take = 5; + } + + const [resultUsers, total] = await UserEntity.findAndCount({ + take: cursorPageOptionsDto.take, + where: [ + { + id: cursorId ? LessThan(cursorId) : null, + isQuit: false, + nickname: Like(`%${searchTerm}%`), + }, + ], + relations: { profileImage: true, follower: true, following: true }, + order: { + id: 'DESC' as any, + }, + }); + + const searchResult = await Promise.all( + resultUsers.map(async (user) => { + const followSearchDto = new FollowSearchDto(); + + console.log('현재의 유저 : ', user.id); + followSearchDto.id = user.id; + followSearchDto.nickName = user.nickname; + followSearchDto.introduction = user.introduction; + + followSearchDto.followerCnt = user.follower.length; + followSearchDto.followingCnt = user.following.length; + + // 팔로우 여부 + followSearchDto.isFollowing = await this.userService.checkIfFollowing( + userEntity, + followSearchDto.id, + ); + + // 사용자 프로필 이미지 + const image = user.profileImage; + if (image == null) followSearchDto.image = null; + else { + const userImageKey = image.imageKey; + followSearchDto.image = await this.s3Service.getImageUrl( + userImageKey, + ); + } + return followSearchDto; + }), + ); + + // (3) 페이징 및 정렬 기준 설정 + let hasNextData = true; + let cursor: number; + + const takePerScroll = cursorPageOptionsDto.take; + const isLastScroll = total <= takePerScroll; + const lastDataPerScroll = resultUsers[resultUsers.length - 1]; + + if (isLastScroll) { + hasNextData = false; + cursor = null; + } else { + cursor = lastDataPerScroll.id; + } + + const cursorPageMetaDto = new CursorPageMetaDto({ + cursorPageOptionsDto, + total, + hasNextData, + cursor, + }); + + return new CursorPageDto(searchResult, cursorPageMetaDto); + } catch (e) { + throw new Error(e.message); + } + } + + // [2] 팔로우 리스트 조회 + async getFollowList(userId: number): Promise { + // 현재 로그인한 사용자 + const user: UserEntity = await this.userService.findUserById(userId); + console.log('현재 로그인한 사용자 : ', user.id); + + // 로그인한 사용자 = 팔로우하는 user + const follows: UserFollowingEntity[] = + await this.userService.getFollowingList(userId); + + // 팔로우 사용자들 정보 리스트 + const informs = await Promise.all( + follows.map(async (follow) => { + const followDto: FollowDto = new FollowDto(); + const mateEntity: UserEntity = follow.followUser; + console.log('팔로우 사용자의 ID : ', mateEntity.id); + + followDto.nickName = mateEntity.nickname; + followDto.mateId = mateEntity.id; + followDto.email = mateEntity.email; + followDto.introduction = mateEntity.introduction; + followDto.isFollowing = !!follow.id; + + // 사용자 프로필 이미지 + const image = await this.userService.getProfileImage(mateEntity.id); + if (image == null) followDto.image = null; + else { + const userImageKey = image.imageKey; + followDto.image = await this.s3Service.getImageUrl(userImageKey); + } + + return followDto; + }), + ); + return informs; + } + + // [3] 팔로워 리스트 조회 + async getFollowerList(userId: number): Promise { + // 현재 로그인한 사용자 + const user: UserEntity = await this.userService.findUserById(userId); + console.log('현재 로그인한 사용자 : ', user.id); + + // 로그인한 사용자 = 팔로워 + const follows: UserFollowingEntity[] = + await this.userService.getFollowerList(userId); + + // 팔로워 사용자들 정보 리스트 + const informs = await Promise.all( + follows.map(async (follow) => { + const followDto: FollowDto = new FollowDto(); + const mateEntity: UserEntity = follow.user; + console.log('팔로워 사용자 ID : ', mateEntity.id); + + followDto.nickName = mateEntity.nickname; + followDto.mateId = mateEntity.id; + followDto.email = mateEntity.email; + followDto.introduction = mateEntity.introduction; + followDto.isFollowing = await this.userService.checkIfFollowing( + user, + mateEntity.id, + ); + + // 사용자 프로필 이미지 + const image = await this.userService.getProfileImage(mateEntity.id); + if (image == null) followDto.image = null; + else { + const userImageKey = image.imageKey; + followDto.image = await this.s3Service.getImageUrl(userImageKey); + } + return followDto; + }), + ); + + return informs; + } + + // [4] 팔로우 가능한 사이인지 검증 + async checkFollow( + userId: number, + followingId: number, + ): Promise { + try { + // case1) 유효한 유저인지 검증 + const userEntity: UserEntity = await UserEntity.findOne({ + where: { id: userId }, + }); + if (!userEntity) + throw new NotFoundException('요청을 보낸 사용자를 찾을 수 없습니다'); + const followingUser = await UserEntity.findOne({ + where: { + id: followingId, + }, + }); + if (followingUser.isQuit == true) + throw new BadRequestException('탈퇴한 회원 입니다'); + if (!followingUser) + throw new NotFoundException('대상 사용자를 찾을 수 없습니다'); + console.log('현재 로그인한 유저 : ', userEntity); + console.log('팔로우 대상 유저 : ', followingUser); + + // case2) 본인을 팔로우한 경우 + if (userId == followingId) + throw new BadRequestException('본인을 팔로우 할 수 없습니다'); + + // case3) 팔로우 관계 확인 + const isAlreadyFollowing = await this.userService.isAlreadyFollowing( + userId, + followingId, + ); + console.log('Is already following? : ', isAlreadyFollowing); + + // [2] 이미 팔로우 한 사이, 팔로우 취소 + if (isAlreadyFollowing) { + console.log('언팔로우 service 호출'); + return this.deleteFollow(userId, followingId); + } else { + // [1] 팔로우 + console.log('팔로우 service 호출'); + return this.createFollow(userId, followingId); + } + } catch (e) { + console.log('팔로우 요청에 실패하였습니다'); + throw new Error(e.message); + } + } + + // [4-1] 팔로우 + async createFollow( + userId: number, + followingId: number, + ): Promise { + try { + const userEntity: UserEntity = await this.userService.findUserById( + userId, + ); + const followingUser = await UserEntity.findExistUser(followingId); + if (!followingUser) + throw new NotFoundException('해당 사용자를 찾을 수 없습니다'); + console.log('현재 로그인한 유저 : ', userEntity); + console.log('팔로우 대상 유저 : ', followingUser); + if (userId == followingId) + throw new BadRequestException('본인을 팔로우 할 수 없습니다'); + + const userFollowingEntity = new UserFollowingEntity(); + userFollowingEntity.user = userEntity; + userFollowingEntity.followUser = followingUser; + + await userFollowingEntity.save(); + return userFollowingEntity; + } catch (e) { + console.log('팔로우 요청에 실패하였습니다'); + throw new Error(e.message); + } + } + + // [4-2] 언팔로우 + async deleteFollow( + userId: number, + followingId: number, + ): Promise { + console.log('언팔로우 서비스 호출'); + const followEntity: UserFollowingEntity = + await UserFollowingEntity.findOneOrFail({ + where: { user: { id: userId }, followUser: { id: followingId } }, + }); + + try { + await followEntity.softRemove(); + return followEntity; + } catch (e) { + console.error('언팔로우 요청에 실패하였습니다: '); + throw new Error(e.message); + } + } +} diff --git a/src/journey/dtos/create-journey.dto.ts b/src/journey/dtos/create-journey.dto.ts new file mode 100644 index 0000000..5cd6b20 --- /dev/null +++ b/src/journey/dtos/create-journey.dto.ts @@ -0,0 +1,13 @@ +// create-journey.dto.ts +import { IsString, IsDateString } from 'class-validator'; + +export class CreateJourneyDto { + @IsString() + title: string; + + @IsDateString() + startDate: string; + + @IsDateString() + endDate: string; +} diff --git a/src/journey/journey.controller.ts b/src/journey/journey.controller.ts new file mode 100644 index 0000000..6ffb133 --- /dev/null +++ b/src/journey/journey.controller.ts @@ -0,0 +1,79 @@ +import { + Controller, + Body, + Req, + UseGuards, + Post, + Delete, + Param, + Put, +} from '@nestjs/common'; +import { ApiOkResponse, ApiOperation } from '@nestjs/swagger'; +import { Request } from 'express'; +import { UserGuard } from 'src/user/user.guard'; +import { JourneyService } from './journey.service'; +import { CreateJourneyDto } from './dtos/create-journey.dto'; + +@Controller('journey') +export class JourneyController { + constructor(private readonly journeyService: JourneyService) {} + /*여정 저장하기*/ + @ApiOperation({ + summary: '여정 저장하기', + description: '날짜와 제목을 포함합니다.', + }) + @ApiOkResponse({ + description: '성공 ', + }) + @UseGuards(UserGuard) + @Post('create') + async createJourney( + @Body() createJourneyDto: CreateJourneyDto, + @Req() req: Request, + ) { + const result = await this.journeyService.createJourney( + req.user, + createJourneyDto, + ); + return result; + } + /*여정 수정하기*/ + @ApiOperation({ + summary: '여정 수정하기', + description: '제목을 수정합니다.', + }) + @ApiOkResponse({ + description: '성공 ', + }) + @UseGuards(UserGuard) + @Put('update/:journeyId') + async updateJourney( + @Body('title') title: string, + @Param('journeyId') journeyId: number, + @Req() req: Request, + ) { + const user = req.user; + const result = await this.journeyService.updateJourney( + user, + journeyId, + title, + ); + return result; + } + + /*여정 삭제하기*/ + @ApiOperation({ + summary: '여정 삭제하기', + description: + '여정을 삭제할때 일정, 세부일정, 일지, 사진을 모두 삭제합니다.', + }) + @ApiOkResponse({ + description: '성공 ', + }) + @UseGuards(UserGuard) + @Delete('delete/:journeyId') + async deleteJourney(@Param('journeyId') journeyId: number) { + const result = await this.journeyService.deleteJourney(journeyId); + return result; + } +} diff --git a/src/journey/journey.module.ts b/src/journey/journey.module.ts new file mode 100644 index 0000000..1d56977 --- /dev/null +++ b/src/journey/journey.module.ts @@ -0,0 +1,10 @@ +// journey.module.ts +import { Module } from '@nestjs/common'; +import { JourneyController } from './journey.controller'; +import { JourneyService } from './journey.service'; + +@Module({ + controllers: [JourneyController], + providers: [JourneyService], +}) +export class JourneyModule {} diff --git a/src/journey/journey.service.ts b/src/journey/journey.service.ts new file mode 100644 index 0000000..10533fa --- /dev/null +++ b/src/journey/journey.service.ts @@ -0,0 +1,132 @@ +// journey.service.ts +import { Injectable } from '@nestjs/common'; +import { addDays, isAfter, isEqual } from 'date-fns'; +import { JourneyEntity } from './model/journey.entity'; +import { errResponse, response } from 'src/response/response'; +import { BaseResponse } from 'src/response/response.status'; +import { ScheduleEntity } from 'src/schedule/schedule.entity'; +import { CreateJourneyDto } from './dtos/create-journey.dto'; +import { DetailScheduleEntity } from 'src/detail-schedule/detail-schedule.entity'; +import { DiaryEntity } from 'src/diary/models/diary.entity'; +import { UserEntity } from 'src/user/user.entity'; +import { DiaryImageEntity } from 'src/diary/models/diary.image.entity'; + +@Injectable() +export class JourneyService { + //여정 생성하기 - 일정, 일지 함께 생성 + async createJourney(user, createJourneyDto: CreateJourneyDto) { + const existJourney = await JourneyEntity.findExistJourneyByPeriod( + user.id, + createJourneyDto, + ); + if (existJourney) { + return errResponse(BaseResponse.JOURNEY_DUPLICATION); + } + //여정 제목, 날짜 저장하기 + const journey = await JourneyEntity.createJourney(user, createJourneyDto); + + //일정 배너 생성하기 : (startDate - endDate + 1)개 + //일정 배너 생성하기 : (startDate - endDate + 1)개 + const startDate = new Date(createJourneyDto.startDate); + const endDate = new Date(createJourneyDto.endDate); + + await this.createSchedules(journey, startDate, endDate); + + return errResponse(BaseResponse.JOURNEY_CREATED); + } + private async createSchedules( + journey: JourneyEntity, + startDate: Date, + endDate: Date, + ) { + const schedules = []; + + // startDate와 endDate가 같은 경우 하나의 schedule 생성 + if (isEqual(startDate, endDate)) { + const schedule = await ScheduleEntity.createSchedule(journey, startDate); + schedules.push(schedule); + } else { + let currentDate = startDate; + // endDate가 startDate 이후인지 확인하여 일정 배너 생성 + if (isAfter(endDate, startDate)) { + while (currentDate <= endDate) { + const schedule = await ScheduleEntity.createSchedule( + journey, + currentDate, + ); + schedules.push(schedule); + currentDate = addDays(currentDate, 1); // 다음 날짜로 이동 + } + } + } + return schedules; + } + + //여정 수정하기 + async updateJourney(user, journeyId: number, title: string) { + const existUser = await UserEntity.findExistUser(user.id); + const journey = await JourneyEntity.findExistJourneyByOptions( + existUser.id, + journeyId, + ); + if (!journey) { + return errResponse(BaseResponse.JOURNEY_NOT_FOUND); + } + if (title === null) { + await JourneyEntity.updateJourney(journey, ''); + return response(BaseResponse.UPDATE_JOURNEY_TITLE_SUCCESS); + } + await JourneyEntity.updateJourney(journey, title); + return response(BaseResponse.UPDATE_JOURNEY_TITLE_SUCCESS); + } + + //여정 삭제하기 - 일정, 일지, + + async deleteJourney(journeyId: number) { + const journey = await JourneyEntity.findExistJourney(journeyId); + const schedules = await ScheduleEntity.findExistSchedulesByJourneyId( + journey.id, + ); + for (const schedule of schedules) { + await this.deleteScheduleRelations(schedule); + } + + await JourneyEntity.deleteJourney(journey); + return response(BaseResponse.DELETE_JOURNEY_SUCCESS); + } + async deleteScheduleRelations(schedule) { + const deleteSchedule = await ScheduleEntity.findExistSchedule(schedule.id); + + //세부 일정 지우기 + const detailSchedules = + await DetailScheduleEntity.findExistDetailByScheduleId(schedule); + for (const detailSchedule of detailSchedules) { + await DetailScheduleEntity.deleteDetailSchedule(detailSchedule); + } + + //일정 지우기 + const diary = await DiaryEntity.findExistDiaryByScheduleId(schedule.id); + if (diary) { + await DiaryEntity.deleteDiary(diary); + } + + await ScheduleEntity.deleteSchedule(deleteSchedule); + } + + //일지 지우기 + async deleteDiaryRelations(diary) { + if (!diary) { + return; // 일지가 없으면 삭제할 필요 없음 + } + // 일지 삭제 + await DiaryEntity.deleteDiary(diary); + + // 연결된 이미지 찾기 + const diaryImg = await DiaryImageEntity.findExistImgUrl(diary); + + // 이미지 삭제 + if (diaryImg) { + await DiaryImageEntity.deleteDiaryImg(diaryImg); + } + } +} diff --git a/src/journey/model/journey.entity.ts b/src/journey/model/journey.entity.ts new file mode 100644 index 0000000..1d0217f --- /dev/null +++ b/src/journey/model/journey.entity.ts @@ -0,0 +1,153 @@ +// journey.entity.ts + +import { + BaseEntity, + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + OneToMany, + ManyToOne, + Between, + LessThanOrEqual, + MoreThanOrEqual, +} from 'typeorm'; +import { isWithinInterval } from 'date-fns'; +import { ScheduleEntity } from 'src/schedule/schedule.entity'; +import { UserEntity } from 'src/user/user.entity'; +import { MonthInfoDto } from 'src/map/month-info.dto'; + +@Entity() +export class JourneyEntity extends BaseEntity { + @PrimaryGeneratedColumn() + id: number; + + @Column() + title: string; + + @Column({ type: 'date' }) + startDate: Date; + + @Column({ type: 'date' }) + endDate: Date; + + @ManyToOne(() => UserEntity, (user) => user.journeys) + user: UserEntity; + + @OneToMany(() => ScheduleEntity, (schedule) => schedule.journey) + schedules: ScheduleEntity[]; + + @CreateDateColumn() + created: Date; + + @UpdateDateColumn() + updated: Date; + + @DeleteDateColumn() + deleted: Date; + + //여정 생성하기 + static async createJourney(user, createJourneyDto) { + try { + const journey: JourneyEntity = new JourneyEntity(); + journey.title = createJourneyDto.title; + journey.startDate = createJourneyDto.startDate; + journey.endDate = createJourneyDto.endDate; + journey.user = user; + + return await journey.save(); + } catch (error) { + throw new Error(error); + } + } + + //여정 수정하기 + static async updateJourney(journey: JourneyEntity, title: string) { + journey.title = title; + return await journey.save(); + } + + //여정 삭제하기 + static async deleteJourney(journey) { + return await JourneyEntity.remove(journey); + } + + //여정 조회 + static async findExistJourney(journeyId: number) { + const journey: JourneyEntity = await JourneyEntity.findOne({ + where: { + id: journeyId, + }, + }); + return journey; + } + + static async findExistJourneysByUserId(userId) { + const journeys: JourneyEntity[] = await JourneyEntity.find({ + where: { user: { id: userId } }, + }); + + return journeys; + } + + static async findExistJourneyByOptions(userId, journeyId) { + const journey: JourneyEntity = await JourneyEntity.findOne({ + where: { + id: journeyId, + user: { id: userId }, + }, + }); + return journey; + } + + static async findExistJourneyByPeriod(userId, createJourneyDto) { + const journey: JourneyEntity = await JourneyEntity.findOne({ + where: { + user: { id: userId }, + startDate: LessThanOrEqual(createJourneyDto.endDate), + endDate: MoreThanOrEqual(createJourneyDto.startDate), + }, + }); + + return journey; + } + + static async findExistJourneyByDate(userId: number, date: Date) { + const journeys: JourneyEntity[] = await JourneyEntity.find({ + where: { + user: { id: userId }, + }, + }); + + // 매개변수로 받은 날짜가 어느 여정에 포함되어 있는지 확인 + const matchingJourney = journeys.find((journey) => { + return isWithinInterval(date, { + start: journey.startDate, + end: journey.endDate, + }); + }); + + return matchingJourney; + } + + //사용자의 월별 여정 조회 + static async findMonthlyJourney(userId: number, dates: MonthInfoDto) { + const firstDate = new Date(`${dates.year}-${dates.month}-01`); + const lastDate = new Date(`${dates.year}-${dates.month}-31`); + const journeys: JourneyEntity[] = await JourneyEntity.find({ + where: [ + { + user: { id: userId }, + startDate: Between(firstDate, lastDate), + }, + { + user: { id: userId }, + endDate: Between(firstDate, lastDate), + }, + ], + }); + return journeys; + } +} diff --git a/src/location/dtos/create-location.dto.ts b/src/location/dtos/create-location.dto.ts new file mode 100644 index 0000000..1530830 --- /dev/null +++ b/src/location/dtos/create-location.dto.ts @@ -0,0 +1,9 @@ +import { IsNumber } from 'class-validator'; + +export class CreateLocationDto { + @IsNumber() + latitude: number; + + @IsNumber() + longitude: number; +} diff --git a/src/location/location.entity.ts b/src/location/location.entity.ts index c057809..3ff79ca 100644 --- a/src/location/location.entity.ts +++ b/src/location/location.entity.ts @@ -1,10 +1,13 @@ +import { ScheduleEntity } from 'src/schedule/schedule.entity'; import { - BaseEntity, Column, + BaseEntity, + Column, CreateDateColumn, DeleteDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn, + OneToMany, } from 'typeorm'; @Entity() @@ -15,14 +18,14 @@ export class LocationEntity extends BaseEntity { @Column() name: string; - @Column() - address: string; - @Column({ type: 'decimal', precision: 10, scale: 6 }) - latitude: string; + latitude: number; @Column({ type: 'decimal', precision: 10, scale: 6 }) - longitude: string; + longitude: number; + + @OneToMany(() => ScheduleEntity, (schedule) => schedule.location) + schedules: ScheduleEntity[]; @CreateDateColumn() created: Date; @@ -32,4 +35,52 @@ export class LocationEntity extends BaseEntity { @DeleteDateColumn() deleted: Date; -} \ No newline at end of file + + //위치 생성하기 + static async createLocation(updateScheduleDto) { + try { + const location: LocationEntity = new LocationEntity(); + location.name = updateScheduleDto.location; + location.latitude = updateScheduleDto.latitude; + location.longitude = updateScheduleDto.longitude; + + return await location.save(); + } catch (error) { + throw new Error(error); + } + } + + //위치 수정하기 + static async updateLocation(location: LocationEntity, updateScheduleDto) { + try { + const updateLocation = await LocationEntity.findOneOrFail({ + where: { id: location.id }, + }); + updateLocation.name = updateScheduleDto.location; + updateLocation.latitude = updateScheduleDto.latitude; + updateLocation.longitude = updateScheduleDto.longitude; + + return await updateLocation.save(); + } catch (error) { + throw new Error(error); + } + } + + //위치 삭제하기 + static async deleteLocation(location) { + return await LocationEntity.remove(location); + } + + static async findExistLocation(updateScheduleDto) { + { + } + const location = await LocationEntity.findOne({ + where: { + name: updateScheduleDto.location, + latitude: updateScheduleDto.latitude, + longitude: updateScheduleDto.longitude, + }, + }); + return location; + } +} diff --git a/src/main.ts b/src/main.ts index 13cad38..e3a03fd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,8 +1,13 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; +import { urlencoded, json } from 'express'; async function bootstrap() { const app = await NestFactory.create(AppModule); + app.enableCors(); + app.setGlobalPrefix('api/v1'); + app.use(json({ limit: '50mb' })); + app.use(urlencoded({ extended: true, limit: '50mb' })); await app.listen(3000); } bootstrap(); diff --git a/src/map/cursor-based-pagination-request.dto.ts.ts b/src/map/cursor-based-pagination-request.dto.ts.ts new file mode 100644 index 0000000..879f8b2 --- /dev/null +++ b/src/map/cursor-based-pagination-request.dto.ts.ts @@ -0,0 +1,25 @@ +// PaginationDto.ts +import { ApiProperty } from '@nestjs/swagger'; +import { IsInt, IsOptional, Min } from 'class-validator'; + +export class CursorBasedPaginationRequestDto { + @ApiProperty({ + description: '커서 값', + required: false, + default: 3, + }) + @IsInt() + @IsOptional() + @Min(0) + cursor?: number = 0; + + @ApiProperty({ + description: '페이지 크기', + required: false, + default: 3, + }) + @IsInt() + @IsOptional() + @Min(1) + pageSize?: number = 3; +} diff --git a/src/map/map.controller.ts b/src/map/map.controller.ts new file mode 100644 index 0000000..5af3c5e --- /dev/null +++ b/src/map/map.controller.ts @@ -0,0 +1,123 @@ +import { MapService } from './map.service'; +import { Controller, Param, Req, UseGuards, Get, Query } from '@nestjs/common'; +import { ApiOkResponse, ApiOperation } from '@nestjs/swagger'; +import { Request } from 'express'; +import { UserGuard } from 'src/user/user.guard'; +import { MonthInfoDto } from './month-info.dto'; +import { CursorBasedPaginationRequestDto } from './cursor-based-pagination-request.dto.ts'; + +@Controller('map') +export class MapController { + constructor(private readonly mapService: MapService) {} + + /*월별 여정 불러오기*/ + @ApiOperation({ + summary: '월별 여정 불러오기', + description: '월별 여정 리스트 - 제목, 날짜, 일지 개수를 불러옵니다.', + }) + @ApiOkResponse({ + description: '성공 ', + }) + @UseGuards(UserGuard) + @Get('get-monthly-journey/:year/:month') + async getMonthlyJourney( + @Param('year') year: number, + @Param('month') month: number, + @Req() req: Request, + ) { + const user = req.user; + const monthInfoDto: MonthInfoDto = { + year, + month, + }; + const result = await this.mapService.getMonthlyJourneyMap( + user.id, + monthInfoDto, + ); + return result; + } + /*월별 일정 불러오기 -캘린더 */ + @ApiOperation({ + summary: '월별 일정 불러오기', + description: + '여정에 포함되는 일정, 위치, 세부 일정, 다이어리 유무를 불러옵니다.', + }) + @ApiOkResponse({ + description: '성공 ', + }) + @UseGuards(UserGuard) + @Get('get-monthly-schedule/:date') + async getMonthlySchedule( + @Param('date') date: Date, + @Query() options: CursorBasedPaginationRequestDto, + @Req() req: Request, + ) { + const user = req.user; + const result = await this.mapService.getMonthlySchedules( + user.id, + date, + options, + ); + return result; + } + + /*여정 불러오기*/ + @ApiOperation({ + summary: '여정 불러오기', + description: '여정 제목, 날짜, 위치, 사진을 불러옵니다.', + }) + @ApiOkResponse({ + description: '성공 ', + }) + @UseGuards(UserGuard) + @Get('get-journey/:journeyId') + async getJourneyPreview( + @Req() req: Request, + @Param('journeyId') journeyId: number, + ) { + const user = req.user; + const result = await this.mapService.getJourneyPreview(user.id, journeyId); + return result; + } + + /*일지 불러오기 - 지도 */ + @ApiOperation({ + summary: '일지 불러오기 - 지도', + description: 'journeyId로 일지 불러오기', + }) + @ApiOkResponse({ + description: '성공 ', + }) + @UseGuards(UserGuard) + @Get('get-diaries/:journeyId') + async getDiaryList( + @Req() req: Request, + @Param('journeyId') journeyId: number, + ) { + const user = req.user; + const result = await this.mapService.getDiaryList(user.id, journeyId); + return result; + } + + /*세부 여정 불러오기 - 지도 */ + @ApiOperation({ + summary: '세부 여정 불러오기 - 지도', + description: 'journeyId로 일정 불러오기', + }) + @ApiOkResponse({ + description: '성공 ', + }) + @UseGuards(UserGuard) + @Get('get-schedules/:journeyId') + async getDetailJourneyList( + @Req() req: Request, + @Param('journeyId') journeyId: number, + ) { + const user = req.user; + const result = await this.mapService.getDetailJourneyList( + user.id, + journeyId, + ); + return result; + } +} diff --git a/src/map/map.module.ts b/src/map/map.module.ts new file mode 100644 index 0000000..14895f4 --- /dev/null +++ b/src/map/map.module.ts @@ -0,0 +1,12 @@ +// Map.module.ts +import { Module } from '@nestjs/common'; +import { MapController } from './map.controller'; +import { MapService } from './map.service'; +import { S3Module } from 'src/utils/S3.module'; + +@Module({ + imports: [S3Module], + controllers: [MapController], + providers: [MapService], +}) +export class MapModule {} diff --git a/src/map/map.service.ts b/src/map/map.service.ts new file mode 100644 index 0000000..4cc52c8 --- /dev/null +++ b/src/map/map.service.ts @@ -0,0 +1,370 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { JourneyEntity } from '../journey/model/journey.entity'; +import { errResponse, response } from 'src/response/response'; +import { BaseResponse } from 'src/response/response.status'; +import { UserEntity } from 'src/user/user.entity'; +import { DiaryEntity } from 'src/diary/models/diary.entity'; +import { ScheduleEntity } from 'src/schedule/schedule.entity'; +import { MonthInfoDto } from './month-info.dto'; +import { DiaryImageEntity } from 'src/diary/models/diary.image.entity'; +import { DetailScheduleEntity } from 'src/detail-schedule/detail-schedule.entity'; +import { CursorBasedPaginationRequestDto } from './cursor-based-pagination-request.dto.ts'; +import { S3UtilService } from 'src/utils/S3.service'; + +@Injectable() +export class MapService { + constructor(private readonly s3UtilService: S3UtilService) {} + + /*캘린더에서 사용자의 월별 일정 불러오기*/ + async getMonthlySchedules( + userId: number, + date: Date, + options: CursorBasedPaginationRequestDto, + ) { + const user = await UserEntity.findExistUser(userId); + const journey = await JourneyEntity.findExistJourneyByDate(user.id, date); + if (!journey) { + return errResponse(BaseResponse.JOURNEY_NOT_FOUND); + } + const schedules = await ScheduleEntity.findExistSchedulesByJourneyId( + journey.id, + ); + if (!schedules) { + return errResponse(BaseResponse.SCHEDULE_NOT_FOUND); + } + const journeyInfo = { + userId: user.id, + journeyId: journey.id, + startDate: journey.startDate, + endDate: journey.endDate, + }; + + const scheduleList = await Promise.all( + schedules.map(async (schedule) => { + const locations = await this.getLocationList([schedule]); // getLocationList에 schedule 배열을 전달 + const detailSchedules = await this.getDetailScheduleList([schedule]); // getDetailScheduleList에 schedule 배열을 전달 + const diary = await this.getDiaryStatus([schedule]); // getDiaryStatus에 schedule 배열을 전달 + + return { + scheduleId: schedule.id, + title: schedule.title, + date: schedule.date, + location: locations, + detailSchedules: detailSchedules, + diary: diary, + }; + }), + ); + // return { + // userId: user.id, + // journeyId: journey.id, + // startDate: journey.startDate, + // endDate: journey.endDate, + // scheduleList: scheduleList, + // }; + + // 페이징 처리 + const startIndex = options.cursor; + const endIndex = Number(options.cursor) + Number(options.pageSize); + const paginatedSchedules = scheduleList.slice(startIndex, endIndex); + + // 다음 페이지를 위한 커서 값 계산 + let nextCursor = null; + if (endIndex < scheduleList.length) { + nextCursor = endIndex; + } + const total = scheduleList.length; + const hasNextData = endIndex < total; + const meta = { + take: options.pageSize, + total: total, + hasNextData: hasNextData, + cursor: nextCursor, + }; + + // 다음 페이지를 위한 커서 값 계산 + // const nextCursor = Number(options.cursor) + Number(options.pageSize); + + return { + data: response(BaseResponse.GET_SCHEDULE_SUCCESS, { + journeyInfo, + paginatedSchedules, + meta, + }), + }; + } + + /*지도에서 사용자의 월별 여정 불러오기*/ + async getMonthlyJourneyMap(userId: number, monthInfoDto: MonthInfoDto) { + const user = await UserEntity.findExistUser(userId); + const monthlyJourney = await this.getMonthlyJourney(user.id, monthInfoDto); + if (monthlyJourney.length === 0) { + return errResponse(BaseResponse.JOURNEY_NOT_FOUND); + } + + const journeyList = await Promise.all( + monthlyJourney.map(async (journey) => { + const schedules = await this.getMonthlySchedule( + journey.id, + monthInfoDto, + ); + console.log(schedules); + const locations = await this.getLocationList(schedules); + const images = await this.getDiaryImageList(schedules); + const mapInfo = schedules.map((schedule, index) => { + return { + date: schedules[index].date, + location: locations[index], + diaryImage: images[index], + }; + }); + const diaryCount = await this.getDiaryCount(schedules); + return { + userId: user.id, + journeyId: journey.id, + title: journey.title, + startDate: journey.startDate, + endDate: journey.endDate, + map: mapInfo, + diaryCount: diaryCount, + }; + }), + ); + return response(BaseResponse.GET_MONTHLY_JOURNEY_SUCCESS, journeyList); + } + + /*지도에서 여정 정보 보여주기*/ + async getJourneyPreview(userId, journeyId) { + const user = await UserEntity.findExistUser(userId); + const journey = await this.getJourneyInfo(journeyId); + const schedules = await ScheduleEntity.findExistSchedulesByJourneyId( + journeyId, + ); + const locationList = await this.getLocationList(schedules); + const imageList = await this.getDiaryImageList(schedules); + const scheduleList = schedules.map((schedule, index) => { + return { + location: locationList[index], + diaryImage: imageList[index], + }; + }); + + return response(BaseResponse.GET_JOURNEY_PREVIEW_SUCCESS, { + userId: user.id, + journey: { + journeyId: journey.id, + title: journey.title, + startDate: journey.startDate, + endDate: journey.endDate, + }, + scheduleList, + }); + } + + /*작성한 일지 불러오기 - 지도*/ + async getDiaryList(userId, journeyId) { + const user = await UserEntity.findExistUser(userId); + const journey = await JourneyEntity.findExistJourney(journeyId); + const schedules = await ScheduleEntity.findExistSchedulesByJourneyId( + journey.id, + ); + const diaryList = await Promise.all( + schedules.map(async (schedule) => { + const diary = await DiaryEntity.findExistDiaryByScheduleId(schedule); + if (!diary) { + return null; + } + const diaryImg = await DiaryImageEntity.findExistImgUrl(diary); + const imageUrl = await this.s3UtilService.getImageUrl( + diaryImg.imageUrl, + ); + if (!diaryImg) { + return null; + } + return { + journeyId: journeyId, + scheduleId: schedule.id, + date: schedule.date, + diary: diary, + diaryImage: { + id: diaryImg.id, + imageUrl: imageUrl, + }, + }; + }), + ); + return response(BaseResponse.GET_DIARY_SUCCESS, { + userId: user.id, + diaryList, + }); + } + + /* 지도에서 세부 여정 확인하기 */ + async getDetailJourneyList(userId, journeyId) { + const user = await UserEntity.findExistUser(userId); + const journey = await this.getJourneyInfo(journeyId); + const schedules = await ScheduleEntity.findExistSchedulesByJourneyId( + journey.id, + ); + const scheduleInfoList = await this.getScheduleList(schedules); + const locationList = await this.getLocationList(schedules); + const imageList = await this.getDiaryImageList(schedules); + const diaryStatus = await this.getDiaryStatus(schedules); + const detailJourneyList = schedules.map((schedule, index) => { + return { + schedule: scheduleInfoList[index], + location: locationList[index], + diaryImage: imageList[index], + diary: diaryStatus[index], + }; + }); + return response(BaseResponse.GET_SCHEDULE_SUCCESS, { + userId: user.id, + detailJourneyList, + }); + } + + //일정 정보 불러오기 + async getScheduleList(schedules: ScheduleEntity[]) { + const scheduleInfoList = await Promise.all( + schedules.map(async (schedule) => { + const scheduleInfo = await ScheduleEntity.findExistSchedule( + schedule.id, + ); + return { + scheduleId: scheduleInfo.id, + title: scheduleInfo.title, + date: scheduleInfo.date, + }; + }), + ); + return scheduleInfoList; + } + + //위치 정보 불러오기 + async getLocationList(schedules: ScheduleEntity[]) { + const locationList = await Promise.all( + schedules.map(async (schedule) => { + const location = await ScheduleEntity.findExistLocation(schedule.id); + console.log(location); + if (!location) { + return { location: null }; + } + return { + locationId: location.id, + name: location.name, + latitude: location.latitude, + longitude: location.longitude, + }; + }), + ); + return locationList; + } + + //이미지 리스트 불러오기 + async getDiaryImageList(schedules: ScheduleEntity[]) { + const diaryImageList = await Promise.all( + schedules.map(async (schedule) => { + const diary = await DiaryEntity.findExistDiaryByScheduleId(schedule); + if (!diary) { + return null; + } + const diaryImage = await DiaryImageEntity.findExistImgUrl(diary); + const imageUrl = await this.s3UtilService.getImageUrl( + diaryImage.imageUrl, + ); + return { + imageId: diaryImage.id, + imageUrl: imageUrl, + }; + }), + ); + return diaryImageList; + } + + //사용자의 월별 여정 가지고 오기 + async getMonthlyJourney(userId, monthInfoDto: MonthInfoDto) { + const journeys = await JourneyEntity.findMonthlyJourney( + userId, + monthInfoDto, + ); + return journeys; + } + + //사용자의 월별 일정 가지고 오기 + async getMonthlySchedule( + journeyId, + monthInfoDto: MonthInfoDto, + ): Promise { + const schedules: ScheduleEntity[] = + await ScheduleEntity.findMonthlySchedule(journeyId, monthInfoDto); + return schedules; + } + + // 사용자의 세부 일정 가지고 오기 + async getDetailScheduleList(schedules: ScheduleEntity[]) { + const detailScheduleList = await Promise.all( + schedules.map(async (schedule) => { + const detailSchedules = + await DetailScheduleEntity.findExistDetailByScheduleId(schedule); + return detailSchedules; + }), + ); + return detailScheduleList; + } + + //여정에 작성한 일지 개수 가지고 오기 + async getDiaryCount(schedules) { + let diaryCount = 0; + for (const schedule of schedules) { + const diary = await DiaryEntity.findExistDiaryByScheduleId(schedule); + if (diary) { + diaryCount += 1; + } + } + return diaryCount; + } + + //일지 작성 여부 가져오기 + async getDiaryStatus(schedules) { + const diaryStatusList = await Promise.all( + schedules.map(async (schedule) => { + const diary = await DiaryEntity.findExistDiaryByScheduleId(schedule); + if (!diary) { + return false; + } + return true; + }), + ); + + return diaryStatusList; + } + + //여정 정보 불러오기 + async getJourneyInfo(journeyId) { + const journey = await JourneyEntity.findExistJourney(journeyId); + if (!journey) { + throw new NotFoundException(BaseResponse.JOURNEY_NOT_FOUND); + } + return { + id: journey.id, + title: journey.title, + startDate: journey.startDate, + endDate: journey.endDate, + }; + } +} + +// const scheduleList = await Promise.all( +// journeys.map(async (journey) => { +// const schedules = await ScheduleEntity.findExistScheduleByJourneyId( +// journey.id, +// ); +// if (!schedules) { +// return errResponse(BaseResponse.SCHEDULE_NOT_FOUND); +// } +// const locations = await this.getLocationList(schedules); +// const detailSchedules = await this.getDetailScheduleList(schedules); +// const diary = await this.getDiaryStatus(schedules); +// }), +// ); diff --git a/src/map/month-info.dto.ts b/src/map/month-info.dto.ts new file mode 100644 index 0000000..ae0b5a6 --- /dev/null +++ b/src/map/month-info.dto.ts @@ -0,0 +1,8 @@ +import { IsInt } from 'class-validator'; + +export class MonthInfoDto { + @IsInt() + year: number; + @IsInt() + month: number; +} diff --git a/src/mate/cursor-page/cursor-page-option.dto.ts b/src/mate/cursor-page/cursor-page-option.dto.ts new file mode 100644 index 0000000..821cc11 --- /dev/null +++ b/src/mate/cursor-page/cursor-page-option.dto.ts @@ -0,0 +1,20 @@ +// cursor-page.options.dto.ts + +import { Type } from 'class-transformer'; +import { IsEnum, IsOptional } from 'class-validator'; +import { Order } from './cursor-page-order.enum'; + +export class CursorPageOptionsDto { + @Type(() => String) + @IsEnum(Order) + @IsOptional() + readonly sort?: Order = Order.DESC; + + @Type(() => Number) + @IsOptional() + readonly take?: number = 5; + + @Type(() => Number) + @IsOptional() + readonly cursorId?: number = '' as any; +} diff --git a/src/mate/cursor-page/cursor-page-options-parameter.interface.ts b/src/mate/cursor-page/cursor-page-options-parameter.interface.ts new file mode 100644 index 0000000..596de7a --- /dev/null +++ b/src/mate/cursor-page/cursor-page-options-parameter.interface.ts @@ -0,0 +1,8 @@ +import { CursorPageOptionsDto } from './cursor-page-option.dto'; + +export interface CursorPageMetaDtoParameters { + cursorPageOptionsDto: CursorPageOptionsDto; + total: number; + hasNextData: boolean; + cursor: number; +} diff --git a/src/mate/cursor-page/cursor-page-order.enum.ts b/src/mate/cursor-page/cursor-page-order.enum.ts new file mode 100644 index 0000000..47e0e41 --- /dev/null +++ b/src/mate/cursor-page/cursor-page-order.enum.ts @@ -0,0 +1,5 @@ +// cursor-page-order.enum.ts +export enum Order { + ASC = 'asc', + DESC = 'desc', +} diff --git a/src/mate/cursor-page/cursor-page.dto.ts b/src/mate/cursor-page/cursor-page.dto.ts new file mode 100644 index 0000000..d53b792 --- /dev/null +++ b/src/mate/cursor-page/cursor-page.dto.ts @@ -0,0 +1,14 @@ +import { IsArray } from 'class-validator'; +import { CursorPageMetaDto } from './cursor-page.meta.dto'; + +export class CursorPageDto { + @IsArray() + readonly data: T[]; + + readonly meta: CursorPageMetaDto; + + constructor(data: T[], meta: CursorPageMetaDto) { + this.data = data; + this.meta = meta; + } +} diff --git a/src/mate/cursor-page/cursor-page.meta.dto.ts b/src/mate/cursor-page/cursor-page.meta.dto.ts new file mode 100644 index 0000000..62198fa --- /dev/null +++ b/src/mate/cursor-page/cursor-page.meta.dto.ts @@ -0,0 +1,22 @@ +// cursor-page.meta.dto.ts + +import { CursorPageMetaDtoParameters } from './cursor-page-options-parameter.interface'; + +export class CursorPageMetaDto { + readonly total: number; + readonly take: number; + readonly hasNextData: boolean; + readonly cursor: number; + + constructor({ + cursorPageOptionsDto, + total, + hasNextData, + cursor, + }: CursorPageMetaDtoParameters) { + this.take = cursorPageOptionsDto.take; + this.total = total; + this.hasNextData = hasNextData; + this.cursor = cursor; + } +} diff --git a/src/mate/dto/mate-profile-response.dto.ts b/src/mate/dto/mate-profile-response.dto.ts new file mode 100644 index 0000000..ae8b4c6 --- /dev/null +++ b/src/mate/dto/mate-profile-response.dto.ts @@ -0,0 +1,14 @@ +// mate-profile-response.dto.ts + +export class MateProfileResponseDto { + _id: number; + image: string; + nickname: string; + introduction: string; + is_followed: boolean; + + signatures: number; // 시그니처 개수 + follower: number; // 팔로워 수 + following: number; // 팔로잉 수 + isQuit: boolean; // 탈퇴 여부 +} diff --git a/src/mate/dto/mate-profile-signature.dto.ts b/src/mate/dto/mate-profile-signature.dto.ts new file mode 100644 index 0000000..ae3edd7 --- /dev/null +++ b/src/mate/dto/mate-profile-signature.dto.ts @@ -0,0 +1,6 @@ +// mate-profile-signature.dto.ts + +export class MateProfileSignatureDto { + _id: number; + image: string; +} diff --git a/src/mate/dto/mate-recommend-profile.dto.ts b/src/mate/dto/mate-recommend-profile.dto.ts new file mode 100644 index 0000000..10297c2 --- /dev/null +++ b/src/mate/dto/mate-recommend-profile.dto.ts @@ -0,0 +1,12 @@ +// mate-recommend-profile.dto.ts + +import { MateSignatureCoverDto } from './mate-signature-cover.dto'; + +export class MateRecommendProfileDto { + _id: number; + mateImage: string; // 유저 사진 + mateName: string; // 유저 별명 + is_followed: boolean; // 팔로우 여부 + introduction: string; // 한 줄 소개 + signatures: MateSignatureCoverDto[]; +} diff --git a/src/mate/dto/mate-signature-cover.dto.ts b/src/mate/dto/mate-signature-cover.dto.ts new file mode 100644 index 0000000..16a93fb --- /dev/null +++ b/src/mate/dto/mate-signature-cover.dto.ts @@ -0,0 +1,8 @@ +// mate-signature-cover.dto.ts + +export class MateSignatureCoverDto { + _id: number; + image: string; + title: string; + liked: number; +} diff --git a/src/mate/dto/mate-with-common-location-response.dto.ts b/src/mate/dto/mate-with-common-location-response.dto.ts new file mode 100644 index 0000000..f98e151 --- /dev/null +++ b/src/mate/dto/mate-with-common-location-response.dto.ts @@ -0,0 +1,9 @@ +// mate-with-common-location.dto.ts + +import { MateRecommendProfileDto } from './mate-recommend-profile.dto'; + +export class MateWithCommonLocationResponseDto { + location: string; + userName: string; // #112 로그인한 사용자 닉네임 추가 + mateProfiles: MateRecommendProfileDto[]; +} diff --git a/src/mate/mate.controller.ts b/src/mate/mate.controller.ts new file mode 100644 index 0000000..e485c4f --- /dev/null +++ b/src/mate/mate.controller.ts @@ -0,0 +1,147 @@ +//mate.controller.ts + +import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common'; +import { UserGuard } from '../user/user.guard'; +import { Request } from 'express'; +import { CursorPageOptionsDto } from './cursor-page/cursor-page-option.dto'; +import { MateService } from './mate.service'; +import { ResponseDto } from '../response/response.dto'; +import { ResponseCode } from '../response/response-code.enum'; +import { MateWithCommonLocationResponseDto } from './dto/mate-with-common-location-response.dto'; +import { MateProfileResponseDto } from './dto/mate-profile-response.dto'; + +@Controller('/mate') +export class MateController { + constructor(private readonly mateService: MateService) {} + + @Get('/random') // 메이트 탐색 첫째 줄: 랜덤으로 메이트 추천 + @UseGuards(UserGuard) + async getRandomMateProfileWithInfiniteCursor( + @Req() req: Request, + @Query() cursorPageOptionDto: CursorPageOptionsDto, + ) { + try { + const result = + await this.mateService.recommendRandomMateWithInfiniteScroll( + cursorPageOptionDto, + req.user.id, + ); + + return new ResponseDto( + ResponseCode.GET_RANDOM_MATE_PROFILE_SUCCESS, + true, + '랜덤 메이트 추천 데이터 생성 성공', + result, + ); + } catch (e) { + console.log(e); + return new ResponseDto( + ResponseCode.GET_RANDOM_MATE_PROFILE_FAIL, + false, + '랜덤 메이트 추천 데이터 생성 실패', + null, + ); + } + } + + @Get('/location') // 메이트 탐색 둘째 줄: 나와 공통 장소를 사용한 메이트 추천 + @UseGuards(UserGuard) + async getMateProfileWithMyFirstLocation( + @Req() req: Request, + ): Promise> { + try { + const result = await this.mateService.getMateProfileWithMyFirstLocation( + req.user.id, + ); + + if (!result) { + return new ResponseDto( + ResponseCode.GET_MATE_WITH_COMMON_LOCATION_SUCCESS, + true, + '공통 메이트가 없거나 내 시그니처가 없습니다.', + null, + ); + } + return new ResponseDto( + ResponseCode.GET_MATE_WITH_COMMON_LOCATION_SUCCESS, + true, + '장소 기반 메이트 추천 리스트 가져오기 성공', + result, + ); + } catch (e) { + console.log(e); + return new ResponseDto( + ResponseCode.GET_MATE_WITH_COMMON_LOCATION_FAIL, + false, + '장소 기반 메이트 추천 실패', + null, + ); + } + } + + @Get(':mateId') + @UseGuards(UserGuard) + async getUserProfile( + @Req() req: Request, + @Param('mateId') mateId: number, + ): Promise> { + try { + const result = await this.mateService.findProfileWithUserId( + req.user.id, + mateId, + ); + + if (!result) { + return new ResponseDto( + ResponseCode.GET_USER_PROFILE_FAIL, + false, + '유저 프로필 정보 가져오기 실패', + null, + ); + } else { + return new ResponseDto( + ResponseCode.GET_USER_PROFILE_SUCCESS, + true, + '유저 프로필 정보 가져오기 성공', + result, + ); + } + } catch (error) { + console.log('Error at GetUserProfile: ', error); + return new ResponseDto( + ResponseCode.GET_USER_PROFILE_FAIL, + false, + '유저 프로필 정보 가져오기 실패', + null, + ); + } + } + + @Get('/signature/:mateId') + async getSignaturesWithInfiniteCursor( + @Param('mateId') mateId: number, + @Query() cursorPageOptionDto: CursorPageOptionsDto, + ) { + try { + const result = await this.mateService.getSignaturesWithInfiniteCursor( + cursorPageOptionDto, + mateId, + ); + + return new ResponseDto( + ResponseCode.GET_USER_SIGNATURES_SUCCESS, + true, + '메이트의 시그니처 가져오기 성공', + result, + ); + } catch (error) { + console.log('Err on getUserSignatures: ', error); + return new ResponseDto( + ResponseCode.GET_USER_SIGNATURES_FAIL, + false, + '메이트의 시그니처 가져오기 실패', + null, + ); + } + } +} diff --git a/src/mate/mate.module.ts b/src/mate/mate.module.ts new file mode 100644 index 0000000..068204e --- /dev/null +++ b/src/mate/mate.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { MateController } from './mate.controller'; +import { MateService } from './mate.service'; +import { UserService } from '../user/user.service'; +import { S3UtilService } from '../utils/S3.service'; +import { SignatureService } from '../signature/signature.service'; + +@Module({ + controllers: [MateController], + providers: [MateService, UserService, S3UtilService, SignatureService], +}) +export class MateModule {} diff --git a/src/mate/mate.service.ts b/src/mate/mate.service.ts new file mode 100644 index 0000000..ee45d4e --- /dev/null +++ b/src/mate/mate.service.ts @@ -0,0 +1,422 @@ +//mate.service.ts + +import { Injectable } from '@nestjs/common'; +import { UserService } from '../user/user.service'; +import { S3UtilService } from '../utils/S3.service'; +import { CursorPageOptionsDto } from './cursor-page/cursor-page-option.dto'; +import { CursorPageDto } from './cursor-page/cursor-page.dto'; +import { SignatureEntity } from '../signature/domain/signature.entity'; +import { SignatureService } from '../signature/signature.service'; +import { LessThan, Like } from 'typeorm'; +import { CursorPageMetaDto } from './cursor-page/cursor-page.meta.dto'; +import { SignaturePageEntity } from '../signature/domain/signature.page.entity'; +import { UserEntity } from '../user/user.entity'; +import { MateRecommendProfileDto } from './dto/mate-recommend-profile.dto'; +import { MateSignatureCoverDto } from './dto/mate-signature-cover.dto'; +import { MateWithCommonLocationResponseDto } from './dto/mate-with-common-location-response.dto'; +import { MateProfileResponseDto } from './dto/mate-profile-response.dto'; + +@Injectable() +export class MateService { + constructor( + private readonly userService: UserService, + private readonly s3Service: S3UtilService, + private readonly signatureService: SignatureService, + ) {} + + async getMateProfileWithMyFirstLocation( + userId: number, + ): Promise { + try { + const mateWithCommonLocationResponseDto = + new MateWithCommonLocationResponseDto(); + + // 1. 메이트 탐색의 기준이 될 장소 가져오기 = 사용자의 가장 최신 시그니처의 첫 번째 페이지 장소 + const mySignaturePageEntity = await SignaturePageEntity.findOne({ + where: { + signature: { + user: { + id: userId, + isQuit: false, // 탈퇴한 사용자 필터링 + }, + }, + page: 1, + }, + order: { + created: 'DESC', // 'created'를 내림차순으로 정렬해서 가장 최근꺼 가져오기 + }, + }); + + if (!mySignaturePageEntity) { + // 로그인한 사용자가 아직 한번도 시그니처를 작성한 적이 없을 경우 + return null; + } + + const longLocation = mySignaturePageEntity.location; + console.log('*longLocation: ', longLocation); + + // 2. 쉼표로 구분된 현재 'longLocation'에서 핵심 부분인 마지막 부분을 가져오기 + const locationBlocks = longLocation.split(','); + const myLocation = locationBlocks[locationBlocks.length - 1].trim(); + console.log('*firstLocation: ', myLocation); + + const loginUser = await this.userService.findUserById(userId); + mateWithCommonLocationResponseDto.location = myLocation; + mateWithCommonLocationResponseDto.userName = loginUser.nickname; + + // 3. 이제 내 기준 로케이션이 사용된 모든 페이지 가져오기 + const commonLocationSignaturePages = await SignaturePageEntity.find({ + where: { location: Like(`%${myLocation}%`) }, + relations: ['signature'], + }); + + // 4. 3번에서 찾아온 페이지의 시그니처 가져오기 + const commonLocationSignatures = []; + for (const page of commonLocationSignaturePages) { + const signature = await SignatureEntity.findOne({ + where: { id: page.signature.id }, + relations: ['user'], + }); + commonLocationSignatures.push(signature); + } + + // 5. 시그니처 작성자 기준으로 분류: 중복된 작성자를 또 찾아오지 않기 위해 + const signatureGroups = {}; + for (const signature of commonLocationSignatures) { + if (!signatureGroups[signature.user.id]) { + // 새로운 유저일 경우 새 리스트 생성, 시그니처 삽입 + signatureGroups[signature.user.id] = []; + signatureGroups[signature.user.id].push(signature); + } + } + + // 6. 유저 아이디 순회하면서 한명씩 찾아서 메이트 프로필 생성하기 + const mateProfiles: MateRecommendProfileDto[] = []; + + for (const authorUserId of Object.keys(signatureGroups)) { + const authorId = Number(authorUserId); + const mate = await this.userService.findUserById(authorId); + + if (userId == authorId) continue; // 본인은 제외 + const locationSignature: SignatureEntity = signatureGroups[authorId][0]; + const mateProfile: MateRecommendProfileDto = + await this.generateMateProfile(mate, userId, locationSignature); + mateProfiles.push(mateProfile); + } + + mateWithCommonLocationResponseDto.mateProfiles = mateProfiles; + + return mateWithCommonLocationResponseDto; + } catch (error) { + console.log('Err: ', error); + throw error; + } + } + + async recommendRandomMateWithInfiniteScroll( + cursorPageOptionsDto: CursorPageOptionsDto, + userId: number, + ) { + let cursorId = 0; + + // [0] 맨 처음 요청일 경우 랜덤 숫자 생성해서 cursorId에 할당 + if (cursorPageOptionsDto.cursorId == 0) { + const newUser = await UserEntity.find({ + where: { isQuit: false }, // 탈퇴 필터링 + order: { + id: 'DESC', // id를 내림차순으로 정렬해서 가장 최근에 가입한 유저 가져오기 + }, + take: 1, + }); + const max = newUser[0].id + 1; // 랜덤 숫자 생성의 max 값 + console.log('max id: ', max); + + const min = 5; // 랜덤 숫자 생성의 min 값 + // TODO 사용자 늘어나면 min 값 늘리기 + cursorId = Math.floor(Math.random() * (max - min + 1)) + min; + console.log('random cursor: ', cursorId); + } else { + cursorId = cursorPageOptionsDto.cursorId; + } + + // [1] 무한 스크롤: take만큼 cursorId보다 id값이 작은 유저들 불러오기 + const [mates, total] = await UserEntity.findAndCount({ + take: cursorPageOptionsDto.take, + where: { + id: LessThan(cursorId), + isQuit: false, + }, + order: { + id: 'DESC' as any, + }, + }); + + console.log('mates: ', mates); + + // [2] 가져온 메이트들 프로필 커버 만들기 + const mateProfiles: MateRecommendProfileDto[] = []; + + for (const mate of mates) { + if (userId == mate.id) continue; // 본인은 제외 + const mateProfile = await this.generateMateProfile(mate, userId, null); + mateProfiles.push(mateProfile); + } + + // [3] 스크롤 설정 + let hasNextData = true; + let cursor: number; + + const takePerScroll = cursorPageOptionsDto.take; + const isLastScroll = total <= takePerScroll; + const lastDataPerScroll = mates[mates.length - 1]; + + if (isLastScroll) { + hasNextData = false; + cursor = null; + } else { + cursor = lastDataPerScroll.id; + } + + const cursorPageMetaDto = new CursorPageMetaDto({ + cursorPageOptionsDto, + total, + hasNextData, + cursor, + }); + + return new CursorPageDto(mateProfiles, cursorPageMetaDto); + } + + async generateMateProfile( + mate: UserEntity, + userId: number, + locationSignature: SignatureEntity, + ) { + const mateProfile = new MateRecommendProfileDto(); + + // 1. 메이트의 기본 정보 담기 + mateProfile._id = mate.id; + mateProfile.mateName = mate.nickname; + mateProfile.introduction = mate.introduction; + + // 2. 로그인한 유저가 메이트를 팔로우하는지 팔로우 여부 체크 + const myEntity = await this.userService.findUserById(userId); + mateProfile.is_followed = await this.userService.checkIfFollowing( + myEntity, + mate.id, + ); + + // 3. 메이트 프로필 사진 가져오기 + const userProfileImage = await this.userService.getProfileImage(mate.id); + if (!userProfileImage) mateProfile.mateImage = null; + else { + const userImageKey = userProfileImage.imageKey; + mateProfile.mateImage = await this.s3Service.getImageUrl(userImageKey); + } + + /**************************************************** + 4. 메이트 대표 시그니처 두 개 구하기 + [1] 랜덤 메이트 추천: 가장 최신 시그니처 두 개 + [2] 장소 기반 추천: 장소 관련 시그니처 1개, 최신 시그니처 1개 + ****************************************************/ + console.log('locationSig: ', locationSignature); + + let recommendSignatures = []; + + if (locationSignature == null) { + // [1] 랜덤 추천이면 가장 최신 시그니처 두 개 가져오기 + recommendSignatures = await this.signatureService.getMyRecentSignatures( + mate.id, + 2, + ); + } else { + // [2] 장소 기반 추천이면 장소 관련 하나, 최신 시그니처 하나 + recommendSignatures.push(locationSignature); + console.log('recommendSignatures: ', recommendSignatures); + + // ㄱ. 삽입할 최신 시그니처 후보 두 개 가져오기 (두 개 중에 이미 삽입된 시그니처와 다른 것을 추가할 것임) + const recentSignatures = + await this.signatureService.getMyRecentSignatures(mate.id, 2); + + // ㄴ. 이미 들어있는 시그니처와 id 비교해서 다르면 삽입 + for (const recentSignature of recentSignatures) { + console.log('recentSignature.id: ', recentSignature.id); + console.log('locationSignature.id: ', locationSignature.id); + + if (recentSignature.id != locationSignature.id) { + // 이미 들어있는 시그니처와 다른 경우에만 push + console.log('push! : ', recentSignature.id); + recommendSignatures.push(recentSignature); + break; + } + } + } + + const signatureCovers = []; + //TODO 작성자가 작성한 시그니처가 하나일 경우에는 리스트에 하나만 담겨있음 프론트에 알리기 -> 완료 + for (const signature of recommendSignatures) { + const signatureCover: MateSignatureCoverDto = new MateSignatureCoverDto(); + + console.log('signature.id: ', signature.id); + signatureCover._id = signature.id; + signatureCover.title = signature.title; + const thumbnailImageKey = await SignaturePageEntity.findThumbnail( + signature.id, + ); + signatureCover.image = await this.s3Service.getImageUrl( + thumbnailImageKey, + ); + + console.log('signatureCover: ', signatureCover); + signatureCovers.push(signatureCover); + } + mateProfile.signatures = signatureCovers; + return mateProfile; + } + + async findProfileWithUserId( + loginUserId: number, + targetUserId, + ): Promise { + // 유저 정보 가져오기 + try { + const targetUserEntity = await this.userService.findUserById( + targetUserId, + ); + console.log(targetUserEntity); + + // 타겟 유저 프로필 가져오기 + const mateProfileResponseDto: MateProfileResponseDto = + new MateProfileResponseDto(); + mateProfileResponseDto._id = targetUserEntity.id; + mateProfileResponseDto.nickname = targetUserEntity.nickname; + mateProfileResponseDto.introduction = targetUserEntity.introduction; + mateProfileResponseDto.isQuit = targetUserEntity.isQuit; + + // 타겟 유저 프로필 이미지 가져오기 + const userProfileImageEntity = await this.userService.getProfileImage( + targetUserId, + ); + if (userProfileImageEntity == null) mateProfileResponseDto.image = null; + else { + const userProfileImageKey = userProfileImageEntity.imageKey; + mateProfileResponseDto.image = await this.s3Service.getImageUrl( + userProfileImageKey, + ); + } + + // 현재 로그인한 유저가 타켓 유저를 팔로우하는지 여부 가져오기 + if (loginUserId == targetUserId) { + // 현재 로그인 유저와 타겟 유저가 같다면 is_followed = null + mateProfileResponseDto.is_followed = null; + } else { + const loginUserEntity = await this.userService.findUserById( + loginUserId, + ); + mateProfileResponseDto.is_followed = + await this.userService.checkIfFollowing( + loginUserEntity, + targetUserId, + ); + } + + // 팔로잉 수 + const followingList = await this.userService.getFollowingList( + targetUserId, + ); + mateProfileResponseDto.following = followingList.length; + + // 팔로워 수 + const followerList = await this.userService.getFollowerList(targetUserId); + mateProfileResponseDto.follower = followerList.length; + + // 시그니처 개수 + mateProfileResponseDto.signatures = + await this.signatureService.getSignatureCnt(targetUserId); + + return mateProfileResponseDto; + } catch (error) { + console.log('Err on findProfileWithId Service: ', error); + throw error; + } + } + + async getSignaturesWithInfiniteCursor( + cursorPageOptionsDto: CursorPageOptionsDto, + mateId: number, + ) { + try { + let cursorId = 0; + + // 1. 맨 처음 요청일 경우 해당 유저의 시그니처 중 가장 최근 시그니처 id 가져오기 + if (cursorPageOptionsDto.cursorId == 0) { + const recentSignature = await SignatureEntity.findOne({ + where: { user: { id: mateId } }, + order: { + id: 'DESC', // id를 내림차순으로 정렬해서 가장 최근에 작성한 시그니처 + }, + }); + + cursorId = recentSignature.id + 1; + } else cursorId = cursorPageOptionsDto.cursorId; + + // 2. 무한 스크롤: take만큼 cursorId보다 id값이 작은 시그니처들 불러오기 + const [signatureEntities, total] = await SignatureEntity.findAndCount({ + take: cursorPageOptionsDto.take, + where: { + id: cursorId ? LessThan(cursorId) : null, + user: { id: mateId }, + }, + order: { + id: 'DESC' as any, + }, + }); + + // 3. 가져온 시그니처들로 커버 만들기 + const signatureCoverDtos: MateSignatureCoverDto[] = []; + + for (const signatureEntity of signatureEntities) { + const signatureCover: MateSignatureCoverDto = + new MateSignatureCoverDto(); + + signatureCover._id = signatureEntity.id; + signatureCover.title = signatureEntity.title; + signatureCover.liked = signatureEntity.liked; + + // 시그니처 썸네일 가져오기 + const imageKey = await SignaturePageEntity.findThumbnail( + signatureEntity.id, + ); + signatureCover.image = await this.s3Service.getImageUrl(imageKey); + + signatureCoverDtos.push(signatureCover); + } + + // 4. 스크롤 설정 + let hasNextData = true; + let cursor: number; + + const takePerScroll = cursorPageOptionsDto.take; + const isLastScroll = total <= takePerScroll; + const lastDataPerScroll = signatureEntities[signatureEntities.length - 1]; + + if (isLastScroll) { + hasNextData = false; + cursor = null; + } else { + cursor = lastDataPerScroll.id; + } + + const cursorPageMetaDto = new CursorPageMetaDto({ + cursorPageOptionsDto, + total, + hasNextData, + cursor, + }); + + return new CursorPageDto(signatureCoverDtos, cursorPageMetaDto); + } catch (error) { + throw error; + } + } +} diff --git a/src/notification/notification.controller.ts b/src/notification/notification.controller.ts new file mode 100644 index 0000000..0736848 --- /dev/null +++ b/src/notification/notification.controller.ts @@ -0,0 +1,21 @@ +import { Controller, Get, Req, UseGuards } from '@nestjs/common'; +import { NotificationService } from './notification.service'; +import { Request } from 'express'; +import { UserGuard } from '../user/user.guard'; + +@Controller('notification') +export class NotificationController { + constructor(private readonly notificationService: NotificationService) {} + + @Get() + @UseGuards(UserGuard) + ListNotification(@Req() req: Request) { + return this.notificationService.listNotifications(req.user.id); + } + + @Get('/unread') + @UseGuards(UserGuard) + CountUnreadNotification(@Req() req: Request) { + return this.notificationService.countUnreadNotification(req.user.id); + } +} diff --git a/src/notification/notification.entity.ts b/src/notification/notification.entity.ts new file mode 100644 index 0000000..90322cf --- /dev/null +++ b/src/notification/notification.entity.ts @@ -0,0 +1,50 @@ +import { + BaseEntity, + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { UserEntity } from '../user/user.entity'; + +@Entity() +export class NotificationEntity extends BaseEntity { + @PrimaryGeneratedColumn() + id: number; + + @JoinColumn() + @ManyToOne(() => UserEntity) + notificationReceiver: UserEntity; + + @JoinColumn() + @ManyToOne(() => UserEntity) + notificationSender: UserEntity; + + @Column({ type: 'enum', enum: ['SIGNATURE', 'RULE'] }) + notificationTargetType: 'SIGNATURE' | 'RULE'; + + @Column() + notificationTargetId: number; + + @Column({ type: 'text' }) + notificationTargetDesc: string; + + @Column({ type: 'enum', enum: ['LIKE', 'COMMENT'] }) + notificationAction: 'LIKE' | 'COMMENT'; + + @Column({ default: false }) + notificationRead: boolean; + + @CreateDateColumn() + created: Date; + + @UpdateDateColumn() + updated: Date; + + @DeleteDateColumn() + deleted: Date; +} diff --git a/src/notification/notification.module.ts b/src/notification/notification.module.ts new file mode 100644 index 0000000..c2ad498 --- /dev/null +++ b/src/notification/notification.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { NotificationService } from './notification.service'; +import { NotificationController } from './notification.controller'; + +@Module({ + controllers: [NotificationController], + providers: [NotificationService], +}) +export class NotificationModule {} diff --git a/src/notification/notification.service.ts b/src/notification/notification.service.ts new file mode 100644 index 0000000..dff1d47 --- /dev/null +++ b/src/notification/notification.service.ts @@ -0,0 +1,86 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { NotificationEntity } from './notification.entity'; +import { ResponseDto } from '../response/response.dto'; +import { ResponseCode } from '../response/response-code.enum'; + +@Injectable() +export class NotificationService { + private readonly logger = new Logger(NotificationService.name); + + async listNotifications(userId: number) { + try { + const notifications = await NotificationEntity.find({ + where: { + notificationReceiver: { + id: userId, + }, + }, + relations: { + notificationSender: true, + }, + order: { + created: 'DESC', + }, + take: 100, + }); + + await NotificationEntity.update( + { + notificationReceiver: { + id: userId, + }, + notificationRead: false, + }, + { + notificationRead: true, + }, + ); + + return new ResponseDto( + ResponseCode.GET_NOTIFICATION_SUCCESS, + true, + '알림 조회 성공', + notifications.map((notification) => ({ + id: notification.id, + content: { + actionUserNickname: notification.notificationSender.nickname, + type: notification.notificationTargetType, + action: notification.notificationAction, + }, + itemId: notification.notificationTargetId, + itemDesc: notification.notificationTargetDesc, + isRead: notification.notificationRead, + created: notification.created, + })), + ); + } catch (e) { + this.logger.error(e); + return new ResponseDto( + ResponseCode.INTERNAL_SERVEr_ERROR, + false, + '서버 내부 오류', + null, + ); + } + } + + async countUnreadNotification(userId: number) { + const unreadCount = await NotificationEntity.count({ + where: { + notificationReceiver: { + id: userId, + }, + notificationRead: false, + }, + }); + + return new ResponseDto( + ResponseCode.GET_NOTIFICATION_COUNT_SUCCESS, + true, + '읽지 않은 알림 개수 조회 성공', + { + unreadCount, + }, + ); + } +} diff --git a/src/place/place.entity.ts b/src/place/place.entity.ts index f9ceaaf..effb5d5 100644 --- a/src/place/place.entity.ts +++ b/src/place/place.entity.ts @@ -3,7 +3,8 @@ import { Column, CreateDateColumn, DeleteDateColumn, - Entity, OneToMany, + Entity, + OneToMany, PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; @@ -21,10 +22,10 @@ export class PlaceEntity extends BaseEntity { @Column({ type: 'text' }) description: string; - @OneToMany(() => PlaceTagEntity, tag => tag.place) + @OneToMany(() => PlaceTagEntity, (tag) => tag.place) tags: PlaceTagEntity[]; - @OneToMany(() => PlaceImageEntity, image => image.place) + @OneToMany(() => PlaceImageEntity, (image) => image.place) images: PlaceImageEntity[]; @CreateDateColumn() @@ -35,4 +36,4 @@ export class PlaceEntity extends BaseEntity { @DeleteDateColumn() deleted: Date; -} \ No newline at end of file +} diff --git a/src/place/place.image.entity.ts b/src/place/place.image.entity.ts index 52d9100..31dca50 100644 --- a/src/place/place.image.entity.ts +++ b/src/place/place.image.entity.ts @@ -1,8 +1,11 @@ import { - BaseEntity, Column, + BaseEntity, + Column, CreateDateColumn, DeleteDateColumn, - Entity, JoinColumn, ManyToOne, + Entity, + JoinColumn, + ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; @@ -14,7 +17,7 @@ export class PlaceImageEntity extends BaseEntity { id: number; @JoinColumn() - @ManyToOne(() => PlaceEntity, place => place.images) + @ManyToOne(() => PlaceEntity, (place) => place.images) place: PlaceEntity; @Column() @@ -28,4 +31,4 @@ export class PlaceImageEntity extends BaseEntity { @DeleteDateColumn() deleted: Date; -} \ No newline at end of file +} diff --git a/src/place/place.tag.entity.ts b/src/place/place.tag.entity.ts index c245ce9..b3e24c7 100644 --- a/src/place/place.tag.entity.ts +++ b/src/place/place.tag.entity.ts @@ -1,8 +1,11 @@ import { - BaseEntity, Column, + BaseEntity, + Column, CreateDateColumn, DeleteDateColumn, - Entity, JoinColumn, ManyToOne, + Entity, + JoinColumn, + ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; @@ -14,7 +17,7 @@ export class PlaceTagEntity extends BaseEntity { id: number; @JoinColumn() - @ManyToOne(() => PlaceEntity, place => place.tags) + @ManyToOne(() => PlaceEntity, (place) => place.tags) place: PlaceEntity; @Column() @@ -28,4 +31,4 @@ export class PlaceTagEntity extends BaseEntity { @DeleteDateColumn() deleted: Date; -} \ No newline at end of file +} diff --git a/src/response/response-code.enum.ts b/src/response/response-code.enum.ts new file mode 100644 index 0000000..3cc7a57 --- /dev/null +++ b/src/response/response-code.enum.ts @@ -0,0 +1,113 @@ +export enum ResponseCode { + /* 200 OK : 요청 성공 */ + SIGNIN_SUCCESS = 'OK', + SIGNOUT_SUCCESS = 'OK', + REISSUE_TOKEN_SUCCESS = 'OK', + GET_MY_SIGNATURES_SUCCESS = 'OK', + GET_SIGNATURE_DETAIL_SUCCESS = 'OK', + DELETE_LIKE_ON_SIGNATURE_SUCCESS = 'OK', + UPDATE_PROFILE_SUCCESS = 'OK', + GET_RULE_DETAIL_SUCCESS = 'OK', + UNFOLLOW_SUCCESS = 'OK', + GET_FOLLOWER_LIST_SUCCESS = 'OK', + GET_FOLLOWING_LIST_SUCCESS = 'OK', + GET_MEMBER_LIST_SUCCESS = 'OK', + DELETE_MEMBER_SUCCESS = 'OK', + RULE_EDIT_SUCCESS = 'OK', + GET_SEARCH_RESULT_SUCCESS = 'OK', + DELETE_INVITATION_SUCCESS = 'OK', + GET_RULE_LIST_SUCCESS = 'OK', + PATCH_RULE_SUCCESS = 'OK', + GET_NOTIFICATION_SUCCESS = 'OK', + GET_NOTIFICATION_COUNT_SUCCESS = 'OK', + GET_DIARY_SUCCESS = 'OK', + + GET_USER_PROFILE_SUCCESS = 'OK', + GET_USER_SIGNATURES_SUCCESS = 'OK', + + COMMENT_UPDATE_SUCCESS = 'OK', + COMMENT_DELETE_SUCCESS = 'OK', + FOLLOW_SUCCESS = 'OK', + GET_COMMENT_DETAIL_SUCCESS = 'OK', + + DELETE_SIGNATURE_SUCCESS = 'OK', + PATCH_SIGNATURE_SUCCESS = 'OK', + GET_LIKE_SIGNATURE_PROFILES_SUCCESS = 'OK', + GET_SEARCH_MAIN_SUCCESS = 'OK', + SEARCH_BY_KEYWORD_SUCCESS = 'OK', + DELETE_ACCOUNT_SUCCESS = 'OK', + GET_MATE_WITH_COMMON_LOCATION_SUCCESS = 'OK', + GET_RANDOM_MATE_PROFILE_SUCCESS = 'OK', + + /* 201 CREATED : 요청 성공, 자원 생성 */ + SIGNUP_SUCCESS = 'CREATED', + SIGNATURE_CREATED = 'CREATED', + RULE_CREATED = 'CREATED', + LIKE_ON_SIGNATURE_CREATED = 'CREATED', + COMMENT_CREATED = 'CREATED', + INVITATION_CREATED = 'CREATED', + CREATE_SIGNATURE_COMMENT_SUCCESS = 'CREATED', + + /* 400 BAD_REQUEST : 잘못된 요청 */ + AUTH_NUMBER_INCORRECT = 'BAD_REQUEST', + RESET_PASSWORD_FAIL_MATCH = 'BAD_REQUEST', + SIGNATURE_CREATION_FAIL = 'BAD_REQUEST', + RULE_CREATION_FAIL = 'BAD_REQUEST', + GET_MY_SIGNATURE_FAIL = 'BAD_REQUEST', + COMMENT_CREATION_FAIL = 'BAD_REQUEST', + GET_RULE_DETAIL_FAIL = 'BAD_REQUEST', + FOLLOW_FAIL = 'BAD_REQUEST', + UNFOLLOW_FAIL = 'BAD_REQUEST', + GET_FOLLOWER_LIST_FAIL = 'BAD_REQUEST', + GET_FOLLOWING_LIST_FAIL = 'BAD_REQUEST', + IS_ALREADY_FOLLOW = 'BAD_REQUEST', + GET_MEMBER_LIST_FAIL = 'BAD_REQUEST', + INVITATION_FAIL = 'BAD_REQUEST', + IS_ALREADY_MEMBER = 'BAD_REQUEST', + IS_NOT_MEMBER = 'BAD_REQUEST', + DELETE_MEMBER_FAIL = 'BAD_REQUEST', + RULE_EDIT_FAIL = 'BAD_REQUEST', + GET_SEARCH_RESULT_FAIL = 'BAD_REQUEST', + DELETE_INVITATION_FAIL = 'BAD_REQUEST', + PATCH_RULE_FAIL = 'BAD_REQUEST', + + GET_MATE_WITH_COMMON_LOCATION_FAIL = 'BAD_REQUEST', + GET_RANDOM_MATE_PROFILE_FAIL = 'BAD_REQUEST', + GET_RULE_LIST_FAIL = 'BAD_REQUEST', + GET_USER_PROFILE_FAIL = 'BAD_REQUEST', + COMMENT_UPDATE_FAIL = 'BAD_REQUEST', + COMMENT_DELETE_FAIL = 'BAD_REQUEST', + GET_COMMENT_DETAIL_FAIL = 'BAD_REQUEST', + + SIGNATURE_DELETE_FAIL = 'BAD_REQUEST', + SIGNATURE_PATCH_FAIL = 'BAD_REQUEST', + GET_LIKE_SIGNATURE_PROFILES_FAIL = 'BAD_REQUEST', + GET_SEARCH_MAIN_FAIL = 'BAD_REQUEST', + SEARCH_BY_KEYWORD_FAIL = 'BAD_REQUEST', + GET_USER_SIGNATURES_FAIL = 'BAD_REQUEST', + + /* 401 UNAUTHORIZED : 인증되지 않은 사용자 */ + INVALID_AUTH_TOKEN = 'UNAUTHORIZED', + INVALID_ACCOUNT = 'UNAUTHORIZED', + UNKNOWN_AUTHENTICATION_ERROR = 'UNAUTHORIZED', + + /* 403 FORBIDDEN : 권한이 없는 사용자 */ + INVALID_REFRESH_TOKEN = 'FORBIDDEN', + HOLDING_WITHDRAWAL = 'FORBIDDEN', + SIGNOUT_FAIL_REFRESH_TOKEN = 'FORBIDDEN', + + /* 404 NOT_FOUND : Resource 를 찾을 수 없음 */ + ACCOUNT_NOT_FOUND = 'NOT_FOUND', + REFRESH_TOKEN_NOT_FOUND = 'NOT_FOUND', + USER_NOT_FOUND = 'NOT_FOUND', + SIGNATURE_NOT_FOUND = 'NOT_FOUND', + INVITATION_NOT_FOUND = 'NOT_FOUND', + + /* 409 CONFLICT : Resource 의 현재 상태와 충돌. 보통 중복된 데이터 존재 */ + EMAIL_DUPLICATION = 'CONFLICT', + USERNAME_DUPLICATION = 'CONFLICT', + NICKNAME_DUPLICATION = 'CONFLICT', + + /* 500 INTERNAL_SERVER_ERROR */ + INTERNAL_SERVEr_ERROR = 'INTERNAL_SERVER_ERROR', +} diff --git a/src/response/response.dto.ts b/src/response/response.dto.ts new file mode 100644 index 0000000..49ad25e --- /dev/null +++ b/src/response/response.dto.ts @@ -0,0 +1,35 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ResponseCode } from './response-code.enum'; + +export class ResponseDto { + @ApiProperty({ description: '응답 시간' }) + timestamp: Date = new Date(); + + @ApiProperty({ description: 'http status code' }) + code: ResponseCode; + + @ApiProperty() + success: boolean; + + @ApiProperty({ example: '데이터 불러오기 성공', description: '응답 메시지' }) + message: string; + + @ApiProperty({ + required: false, + nullable: true, + description: 'Response Body', + }) + data?: T; + + public constructor( + code: ResponseCode, + success: boolean, + message: string, + data: T, + ) { + this.code = code; + this.success = success; + this.message = message; + this.data = data; + } +} diff --git a/src/response/response.status.ts b/src/response/response.status.ts new file mode 100644 index 0000000..4610971 --- /dev/null +++ b/src/response/response.status.ts @@ -0,0 +1,132 @@ +export const BaseResponse = { + /* 200 OK : 요청 성공 */ + DELETE_JOURNEY_SUCCESS: { + success: true, + code: 200, + message: '여정을 삭제했습니다.', + }, + DELETE_SCHEDULE_SUCCESS: { + success: true, + code: 200, + message: '일정을 삭제했습니다.', + }, + DELETE_DIARY_SUCCESS: { + success: true, + code: 200, + message: '일지를 삭제했습니다.', + }, + DELETE_DETAIL_SCHEDULE_SUCCESS: { + success: true, + code: 200, + message: '세부 일정을 삭제했습니다.', + }, + UPDATE_JOURNEY_TITLE_SUCCESS: { + success: true, + code: 200, + message: '여정 제목을 수정했습니다', + }, + UPDATE_DETAIL_SCHEDULE_STATUS_SUCCESS: { + success: true, + code: 200, + message: '세부 일정 상태를 변경했습니다', + }, + GET_MONTHLY_JOURNEY_SUCCESS: { + success: true, + code: 200, + message: '월별 여정을 불러오는데 성공했습니다.', + }, + GET_JOURNEY_PREVIEW_SUCCESS: { + success: true, + code: 200, + message: '여정을 불러오는데 성공했습니다.', + }, + GET_DIARY_SUCCESS: { + success: true, + code: 200, + message: '일지를 불러오는데 성공했습니다.', + }, + GET_SCHEDULE_SUCCESS: { + success: true, + code: 200, + message: '일정를 불러오는데 성공했습니다.', + }, + + /* 201 CREATED : 요청 성공, 자원 생성 */ + DATEGROUP_CREATED: { + success: true, + code: 201, + message: '날짜 그룹이 생성되었습니다.', + }, + JOURNEY_CREATED: { + success: true, + code: 201, + message: '여정이 저장되었습니다.', + }, + SCHEDULE_UPDATED: { + success: true, + code: 201, + message: '일정을 작성했습니다.', + }, + + DETAIL_SCHEDULE_CREATED: { + success: true, + code: 201, + message: '세부 일정을 추가했습니다.', + }, + DETAIL_SCHEDULE_UPDATED: { + success: true, + code: 201, + message: '세부 일정을 작성했습니다.', + }, + DIARY_CREATED: { + success: true, + code: 201, + message: '일지를 작성했습니다.', + }, + DIARY_UPDATED: { + success: true, + code: 201, + message: '일지를 수정했습니다.', + }, + DIARY_IMG_URL_CREATED: { + success: true, + code: 201, + message: '이미지 url이 발급되었습니다.', + }, + + /* 404 NOT_FOUND : Resource 를 찾을 수 없음 */ + + USER_NOT_FOUND: { + success: false, + code: 404, + message: '사용자가 없습니다.', + }, + + JOURNEY_NOT_FOUND: { + success: false, + code: 404, + message: '아직 작성한 여정이 없어요!', + }, + + SCHEDULE_NOT_FOUND: { + success: false, + code: 404, + message: '일정이 없습니다.', + }, + DETAIL_SCHEDULE_NOT_FOUND: { + success: false, + code: 404, + message: '세부 일정이 없습니다.', + }, + DIARY_NOT_FOUND: { + success: false, + code: 404, + message: '일지가 없습니다.', + }, + /* 409 CONFLICT : Resource 의 현재 상태와 충돌. 보통 중복된 데이터 존재 */ + JOURNEY_DUPLICATION: { + success: false, + code: 409, + message: '이미 여정이 있습니다.', + }, +}; diff --git a/src/response/response.ts b/src/response/response.ts new file mode 100644 index 0000000..aacddc4 --- /dev/null +++ b/src/response/response.ts @@ -0,0 +1,16 @@ +export const response = ({ success, code, message }, data = null) => { + return { + success: success, + code: code, + message: message, + data: data, + }; +}; + +export const errResponse = ({ success, code, message }) => { + return { + success: success, + code: code, + message: message, + }; +}; diff --git a/src/rule/domain/rule.invitation.entity.ts b/src/rule/domain/rule.invitation.entity.ts new file mode 100644 index 0000000..63e5036 --- /dev/null +++ b/src/rule/domain/rule.invitation.entity.ts @@ -0,0 +1,93 @@ +import { + BaseEntity, + Entity, + ManyToOne, + PrimaryGeneratedColumn, + JoinColumn, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, +} from 'typeorm'; +import { UserEntity } from 'src/user/user.entity'; +import { RuleMainEntity } from './rule.main.entity'; + +@Entity() +export class RuleInvitationEntity extends BaseEntity { + @PrimaryGeneratedColumn() + id: number; + + @ManyToOne(() => RuleMainEntity, (ruleMain) => ruleMain.invitations) + @JoinColumn({ name: 'rule_id' }) + rule: RuleMainEntity; + + @ManyToOne(() => UserEntity, (user) => user.ruleParticipate) + @JoinColumn({ name: 'member_id' }) + member: UserEntity; + + @CreateDateColumn() + created: Date; + + @UpdateDateColumn() + updated: Date; + + @DeleteDateColumn() + deleted: Date; + + static async findNameById( + inviterId: number, + ): Promise<{ memberId: number; name: string }> { + const userEntity: UserEntity = await UserEntity.findOne({ + where: { id: inviterId }, + }); + const memberId = inviterId; + const name = userEntity.name; + + return { memberId, name }; + } + + static async findInvitationByRuleId( + ruleId: number, + ): Promise { + try { + const invitation = await RuleInvitationEntity.find({ + where: { rule: { id: ruleId } }, + relations: { member: true }, + }); + console.log('invitation 조회 결과 : ', invitation); + return invitation; + } catch (error) { + console.log('Error on findInvitationByRuleId: ', error); + throw error; + } + } + + static async findInvitationByRuleAndUser( + ruleId: number, + userId: number, + ): Promise { + try { + const invitation = await RuleInvitationEntity.findOne({ + where: [{ rule: { id: ruleId } }, { member: { id: userId } }], + }); + console.log('invitation 조회 결과 : ', invitation); + return invitation; + } catch (error) { + console.log('Error on findInvitationByRuleId: ', error); + throw error; + } + } + + // [member] 멤버인지 확인 + static async isAlreadyMember( + ruleId: number, + targetUserId: number, + ): Promise { + const isAlreadyMember = await RuleInvitationEntity.findOne({ + where: { member: { id: targetUserId }, rule: { id: ruleId } }, + }); + console.log(isAlreadyMember); + + if (!!isAlreadyMember) return true; + else return false; + } +} diff --git a/src/rule/domain/rule.main.entity.ts b/src/rule/domain/rule.main.entity.ts new file mode 100644 index 0000000..92f8cf4 --- /dev/null +++ b/src/rule/domain/rule.main.entity.ts @@ -0,0 +1,64 @@ +import { + BaseEntity, + Column, + Entity, + PrimaryGeneratedColumn, + OneToMany, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, +} from 'typeorm'; +import { RuleSubEntity } from './rule.sub.entity'; +import { RuleInvitationEntity } from './rule.invitation.entity'; +import { CommentEntity } from 'src/comment/domain/comment.entity'; + +@Entity() +export class RuleMainEntity extends BaseEntity { + @PrimaryGeneratedColumn({ type: 'bigint' }) + id: number; + + @Column({ type: 'varchar', length: 200 }) + mainTitle: string; + + @CreateDateColumn() + created: Date; + + @UpdateDateColumn() + updated: Date; + + @DeleteDateColumn() + deleted: Date; + + @OneToMany(() => RuleSubEntity, (ruleSub) => ruleSub.main) + rules: RuleSubEntity[]; + + @OneToMany( + () => RuleInvitationEntity, + (ruleInvitation) => ruleInvitation.rule, + ) + invitations: RuleInvitationEntity[]; + + @OneToMany(() => CommentEntity, (comment) => comment.rule) + comments: CommentEntity[]; + + static async findMainById(ruleId: number): Promise { + const ruleMain: RuleMainEntity = await RuleMainEntity.findOne({ + where: { id: ruleId }, + relations: ['rules', 'invitations', 'comments'], + }); + + return ruleMain; + } + + static async findRuleById(ruleId: number): Promise { + try { + const rule: RuleMainEntity = await RuleMainEntity.findOne({ + where: { id: ruleId }, + }); + return rule; + } catch (error) { + console.log('Error on findRuleById: ', error); + throw error; + } + } +} diff --git a/src/rule/domain/rule.sub.entity.ts b/src/rule/domain/rule.sub.entity.ts new file mode 100644 index 0000000..bdc49cc --- /dev/null +++ b/src/rule/domain/rule.sub.entity.ts @@ -0,0 +1,49 @@ +import { + BaseEntity, + Column, + Entity, + ManyToOne, + PrimaryGeneratedColumn, + JoinColumn, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, +} from 'typeorm'; +import { RuleMainEntity } from './rule.main.entity'; + +@Entity() +export class RuleSubEntity extends BaseEntity { + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: 'varchar', length: 200 }) + ruleTitle: string; + + @Column({ type: 'text' }) + ruleDetail: string; + + @ManyToOne(() => RuleMainEntity, (ruleMain) => ruleMain.rules) + @JoinColumn({ name: 'rule_id' }) + main: RuleMainEntity; + + @CreateDateColumn() + created: Date; + + @UpdateDateColumn() + updated: Date; + + @DeleteDateColumn() + deleted: Date; + + static async findSubById(ruleId: number): Promise { + try { + const rule: RuleSubEntity[] = await RuleSubEntity.find({ + where: { main: { id: ruleId } }, + }); + return rule; + } catch (error) { + console.log('Error on findRuleById: ', error); + throw error; + } + } +} diff --git a/src/rule/dto/create-rule.dto.ts b/src/rule/dto/create-rule.dto.ts new file mode 100644 index 0000000..3d0f299 --- /dev/null +++ b/src/rule/dto/create-rule.dto.ts @@ -0,0 +1,37 @@ +import { + IsNotEmpty, + IsNumber, + IsString, + IsArray, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +class RulePairDto { + @IsNotEmpty() + @IsNumber() + ruleNumber: number; + + @IsNotEmpty() + @IsString() + ruleTitle: string; + + @IsNotEmpty() + @IsString() + ruleDetail: string; +} + +export class CreateRuleDto { + @IsNotEmpty() + @IsString() + mainTitle: string; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => RulePairDto) + rulePairs: RulePairDto[]; + + @IsNotEmpty() + @IsArray() + @IsNumber({}, { each: true }) + membersId: number[]; +} diff --git a/src/rule/dto/cursor-page-options-parameter.interface.ts b/src/rule/dto/cursor-page-options-parameter.interface.ts new file mode 100644 index 0000000..403b32e --- /dev/null +++ b/src/rule/dto/cursor-page-options-parameter.interface.ts @@ -0,0 +1,8 @@ +import { CursorPageOptionsDto } from './cursor-page.options.dto'; + +export interface CursorPageMetaDtoParameters { + cursorPageOptionsDto: CursorPageOptionsDto; + total: number; + hasNextData: boolean; + cursor: number; +} diff --git a/src/rule/dto/cursor-page-order.enum.ts b/src/rule/dto/cursor-page-order.enum.ts new file mode 100644 index 0000000..9b0a631 --- /dev/null +++ b/src/rule/dto/cursor-page-order.enum.ts @@ -0,0 +1,4 @@ +export enum Order { + ASC = 'asc', + DESC = 'desc', +} diff --git a/src/rule/dto/cursor-page.dto.ts b/src/rule/dto/cursor-page.dto.ts new file mode 100644 index 0000000..d53b792 --- /dev/null +++ b/src/rule/dto/cursor-page.dto.ts @@ -0,0 +1,14 @@ +import { IsArray } from 'class-validator'; +import { CursorPageMetaDto } from './cursor-page.meta.dto'; + +export class CursorPageDto { + @IsArray() + readonly data: T[]; + + readonly meta: CursorPageMetaDto; + + constructor(data: T[], meta: CursorPageMetaDto) { + this.data = data; + this.meta = meta; + } +} diff --git a/src/rule/dto/cursor-page.meta.dto.ts b/src/rule/dto/cursor-page.meta.dto.ts new file mode 100644 index 0000000..a57b55d --- /dev/null +++ b/src/rule/dto/cursor-page.meta.dto.ts @@ -0,0 +1,20 @@ +import { CursorPageMetaDtoParameters } from './cursor-page-options-parameter.interface'; + +export class CursorPageMetaDto { + readonly total: number; + readonly take: number; + readonly hasNextData: boolean; + readonly cursor: number; + + constructor({ + cursorPageOptionsDto, + total, + hasNextData, + cursor, + }: CursorPageMetaDtoParameters) { + this.take = cursorPageOptionsDto.take; + this.total = total; + this.hasNextData = hasNextData; + this.cursor = cursor; + } +} diff --git a/src/rule/dto/cursor-page.options.dto.ts b/src/rule/dto/cursor-page.options.dto.ts new file mode 100644 index 0000000..2a71402 --- /dev/null +++ b/src/rule/dto/cursor-page.options.dto.ts @@ -0,0 +1,18 @@ +import { Type } from 'class-transformer'; +import { IsEnum, IsOptional } from 'class-validator'; +import { Order } from './cursor-page-order.enum'; + +export class CursorPageOptionsDto { + @Type(() => String) + @IsEnum(Order) + @IsOptional() + sort?: Order = Order.DESC; + + @Type(() => Number) + @IsOptional() + take?: number = 5; + + @Type(() => Number) + @IsOptional() + cursorId?: number = '' as any; +} diff --git a/src/rule/dto/detail.rule.dto.ts b/src/rule/dto/detail.rule.dto.ts new file mode 100644 index 0000000..6d956ae --- /dev/null +++ b/src/rule/dto/detail.rule.dto.ts @@ -0,0 +1,53 @@ +import { + IsNotEmpty, + IsNumber, + IsString, + IsArray, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class RulePairDto { + @IsNotEmpty() + @IsNumber() + id: number; + + @IsNotEmpty() + @IsString() + ruleTitle: string; + + @IsNotEmpty() + @IsString() + ruleDetail: string; +} + +export class DetailMemberDto { + @IsNumber() + id: number; + + @IsString() + name: string; + + @IsString() + image: string; +} + +export class DetailRuleDto { + @IsNotEmpty() + @IsNumber() + id: number; + + @IsNotEmpty() + @IsString() + mainTitle: string; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => RulePairDto) + rulePairs: RulePairDto[]; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => DetailMemberDto) + detailMembers: DetailMemberDto[]; +} diff --git a/src/rule/dto/get-comment.dto.ts b/src/rule/dto/get-comment.dto.ts new file mode 100644 index 0000000..5fdec99 --- /dev/null +++ b/src/rule/dto/get-comment.dto.ts @@ -0,0 +1,34 @@ +import { + IsDate, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, +} from 'class-validator'; + +export class GetCommentDto { + @IsNotEmpty() + @IsNumber() + id: number; + + @IsNotEmpty() + @IsString() + content: string; + + @IsNotEmpty() + @IsDate() + updated: Date; + + // 탈퇴한 회원이 작성한 댓글도 표시 -> null 허용 + @IsOptional() + @IsNumber() + writerId: number; + + @IsOptional() + @IsString() + name: string; + + @IsOptional() + @IsString() + image: string; +} diff --git a/src/rule/dto/get-member-list.dto.ts b/src/rule/dto/get-member-list.dto.ts new file mode 100644 index 0000000..ccd8ccb --- /dev/null +++ b/src/rule/dto/get-member-list.dto.ts @@ -0,0 +1,23 @@ +import { IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator'; + +export class GetMemberListDto { + @IsNotEmpty() + @IsNumber() + id: number; + + @IsNotEmpty() + @IsString() + name: string; + + @IsNotEmpty() + @IsString() + email: string; + + @IsOptional() + @IsString() + introduction: string; + + @IsOptional() + @IsString() + image: string; +} diff --git a/src/rule/dto/get-rule-list.dto.ts b/src/rule/dto/get-rule-list.dto.ts new file mode 100644 index 0000000..e59e85d --- /dev/null +++ b/src/rule/dto/get-rule-list.dto.ts @@ -0,0 +1,46 @@ +import { + IsArray, + IsDate, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +export class MemberPairDto { + @IsNotEmpty() + @IsNumber() + id: number; + + @IsNotEmpty() + @IsString() + name: string; + + @IsOptional() + @IsString() + image: string; +} + +export class GetRuleListDto { + @IsNotEmpty() + @IsNumber() + id: number; + + @IsNotEmpty() + @IsString() + title: string; + + @IsNotEmpty() + @IsDate() + updated: Date; + + @IsNotEmpty() + @IsNumber() + memberCnt: number; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => MemberPairDto) + memberPairs: MemberPairDto[]; +} diff --git a/src/rule/dto/get-search-member-at-create.dto.ts b/src/rule/dto/get-search-member-at-create.dto.ts new file mode 100644 index 0000000..3f78b27 --- /dev/null +++ b/src/rule/dto/get-search-member-at-create.dto.ts @@ -0,0 +1,23 @@ +import { IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator'; + +export class GetSearchMemberAtCreateDto { + @IsNotEmpty() + @IsNumber() + id: number; + + @IsNotEmpty() + @IsString() + name: string; + + @IsNotEmpty() + @IsString() + email: string; + + @IsOptional() + @IsString() + introduction: string; + + @IsOptional() + @IsString() + image: string; +} diff --git a/src/rule/dto/get-search-member.dto.ts b/src/rule/dto/get-search-member.dto.ts new file mode 100644 index 0000000..ee826b6 --- /dev/null +++ b/src/rule/dto/get-search-member.dto.ts @@ -0,0 +1,33 @@ +import { + IsBoolean, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, +} from 'class-validator'; + +export class GetSearchMemberDto { + @IsNotEmpty() + @IsNumber() + id: number; + + @IsNotEmpty() + @IsString() + name: string; + + @IsNotEmpty() + @IsString() + email: string; + + @IsOptional() + @IsString() + introduction: string; + + @IsOptional() + @IsString() + image: string; + + @IsOptional() + @IsBoolean() + isInvited: boolean; +} diff --git a/src/rule/dto/update-rule.dto.ts b/src/rule/dto/update-rule.dto.ts new file mode 100644 index 0000000..1347f89 --- /dev/null +++ b/src/rule/dto/update-rule.dto.ts @@ -0,0 +1,42 @@ +import { + IsNotEmpty, + IsNumber, + IsString, + IsArray, + ValidateNested, + IsOptional, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class UpdateRulePairDto { + @IsOptional() + @IsNumber() + id: number; + + @IsNotEmpty() + @IsNumber() + ruleNumber: number; + + @IsNotEmpty() + @IsString() + ruleTitle: string; + + @IsNotEmpty() + @IsString() + ruleDetail: string; +} + +export class UpdateRuleDto { + @IsNotEmpty() + @IsString() + mainTitle: string; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => UpdateRulePairDto) + rulePairs: UpdateRulePairDto[]; + + @IsArray() + @IsNumber({}, { each: true }) + membersId: number[]; +} diff --git a/src/rule/rule.controller.ts b/src/rule/rule.controller.ts new file mode 100644 index 0000000..cbd83fb --- /dev/null +++ b/src/rule/rule.controller.ts @@ -0,0 +1,281 @@ +import { + Controller, + Post, + Body, + Get, + Param, + Delete, + UseGuards, + Req, + Query, + Patch, +} from '@nestjs/common'; +import { RuleService } from './rule.service'; +import { CreateRuleDto } from './dto/create-rule.dto'; +import { ResponseCode } from '../response/response-code.enum'; +import { ResponseDto } from '../response/response.dto'; +import { UserGuard } from '../user/user.guard'; +import { Request } from 'express'; +import { GetSearchMemberDto } from './dto/get-search-member.dto'; +import { UpdateRuleDto } from './dto/update-rule.dto'; +import { CursorPageOptionsDto } from './dto/cursor-page.options.dto'; +import { CursorPageDto } from './dto/cursor-page.dto'; +import { GetSearchMemberAtCreateDto } from './dto/get-search-member-at-create.dto'; + +@Controller('mate/rule') +export class RuleController { + constructor(private readonly ruleService: RuleService) {} + + // [1] 여행 규칙 상세 페이지 조회 (댓글) - 무한 스크롤 적용 + @Get('/detail/comment/:ruleId') + @UseGuards(UserGuard) + async getComment( + @Req() req: Request, + @Param('ruleId') ruleId: number, + @Query() cursorPageOptionsDto: CursorPageOptionsDto, + ): Promise> { + try { + const result = await this.ruleService.getComment( + cursorPageOptionsDto, + ruleId, + req.user.id, + ); + + return new ResponseDto( + ResponseCode.GET_COMMENT_DETAIL_SUCCESS, + true, + '여행 규칙 상세 페이지 (댓글) 조회 성공', + result, + ); + } catch (e) { + return new ResponseDto( + ResponseCode.GET_COMMENT_DETAIL_FAIL, + false, + e.message, + null, + ); + } + } + + // [2] 여행 규칙 멤버 리스트 조회 + @Get('/detail/member/:ruleId') + @UseGuards(UserGuard) + async getMemberList( + @Req() req: Request, + @Param('ruleId') ruleId: number, + ): Promise> { + try { + const memberList = await this.ruleService.getMemberList( + req.user.id, + ruleId, + ); + return new ResponseDto( + ResponseCode.GET_MEMBER_LIST_SUCCESS, + true, + '여행 규칙 멤버 리스트 불러오기 성공', + memberList, + ); + } catch (e) { + return new ResponseDto( + ResponseCode.GET_MEMBER_LIST_FAIL, + false, + e.message, + null, + ); + } + } + + // [3] 여행 규칙 참여 멤버로 초대할 메이트 검색 결과 + // [3-1] case1. 여행 규칙 생성 + @Get('/detail/search') + @UseGuards(UserGuard) + async getSearchMemberAtCreate( + @Query('searchTerm') searchTerm: string, + @Query() cursorPageOptionsDto: CursorPageOptionsDto, + @Req() req: Request, + ): Promise> { + try { + const result: CursorPageDto = + await this.ruleService.getSearchMemberAtCreate( + cursorPageOptionsDto, + req.user.id, + searchTerm, + ); + return new ResponseDto( + ResponseCode.GET_SEARCH_RESULT_SUCCESS, + true, + '초대할 메이트 검색 결과 리스트 불러오기 성공', + result, + ); + } catch (e) { + return new ResponseDto( + ResponseCode.GET_SEARCH_RESULT_FAIL, + false, + e.message, + null, + ); + } + } + + // [3-2] case2. 여행 규칙 수정 + @Get('/detail/search/:ruleId') + @UseGuards(UserGuard) + async getSearchMemberAtUpdate( + @Query('searchTerm') searchTerm: string, + @Query() cursorPageOptionsDto: CursorPageOptionsDto, + @Param('ruleId') ruleId: number, + @Req() req: Request, + ): Promise> { + try { + const result: CursorPageDto = + await this.ruleService.getSearchMemberAtUpdate( + cursorPageOptionsDto, + req.user.id, + ruleId, + searchTerm, + ); + return new ResponseDto( + ResponseCode.GET_SEARCH_RESULT_SUCCESS, + true, + '초대할 메이트 검색 결과 리스트 불러오기 성공', + result, + ); + } catch (e) { + return new ResponseDto( + ResponseCode.GET_SEARCH_RESULT_FAIL, + false, + e.message, + null, + ); + } + } + + // [4] 여행 규칙 상세 페이지 조회 (게시글) + @Get('/detail/:ruleId') + @UseGuards(UserGuard) + async getDetail( + @Req() req: Request, + @Param('ruleId') ruleId: number, + ): Promise> { + await this.ruleService.getDetail(req.user.id, ruleId); + + try { + const result = await this.ruleService.getDetail(req.user.id, ruleId); + return new ResponseDto( + ResponseCode.GET_RULE_DETAIL_SUCCESS, + true, + '여행 규칙 상세 페이지 (게시글) 조회 성공', + result, + ); + } catch (e) { + return new ResponseDto( + ResponseCode.GET_RULE_DETAIL_FAIL, + false, + e.message, + null, + ); + } + } + + // [5] 여행 규칙 수정 + @Patch('/detail/:ruleId') + @UseGuards(UserGuard) + async updateRule( + @Body() updateRuleDto: UpdateRuleDto, + @Req() req: Request, + @Param('ruleId') ruleId: number, + ): Promise> { + try { + const result = await this.ruleService.updateRule( + updateRuleDto, + req.user.id, + ruleId, + ); + return new ResponseDto( + ResponseCode.PATCH_RULE_SUCCESS, + true, + '여행 규칙 수정 성공', + result, + ); + } catch (e) { + return new ResponseDto( + ResponseCode.PATCH_RULE_FAIL, + false, + e.message, + null, + ); + } + } + + // [6] 여행 규칙 생성 + @Post('/detail') + @UseGuards(UserGuard) + async createRule( + @Req() req: Request, + @Body() createRuleDto: CreateRuleDto, + ): Promise> { + try { + const result = await this.ruleService.createRule( + createRuleDto, + req.user.id, + ); + return new ResponseDto( + ResponseCode.RULE_CREATED, + true, + '여행 규칙 생성 성공', + result, + ); + } catch (e) { + return new ResponseDto( + ResponseCode.RULE_CREATION_FAIL, + false, + e.message, + null, + ); + } + } + + // [7] 여행 규칙 나가기 + @Delete('/:ruleId') + @UseGuards(UserGuard) + async deleteInvitation(@Req() req: Request, @Param('ruleId') ruleId: number) { + try { + await this.ruleService.deleteInvitation(ruleId, req.user.id); + return new ResponseDto( + ResponseCode.DELETE_INVITATION_SUCCESS, + true, + '여행 규칙 나가기 성공', + null, + ); + } catch (e) { + return new ResponseDto( + ResponseCode.DELETE_INVITATION_FAIL, + false, + e.message, + null, + ); + } + } + + // [8] 여행 규칙 전체 리스트 조회 + @Get() + @UseGuards(UserGuard) + async getRuleList(@Req() req: Request): Promise> { + try { + const result = await this.ruleService.getRuleList(req.user.id); + return new ResponseDto( + ResponseCode.GET_RULE_LIST_SUCCESS, + true, + '여행 규칙 전체 리스트 조회 성공', + result, + ); + } catch (e) { + return new ResponseDto( + ResponseCode.GET_RULE_LIST_FAIL, + false, + e.message, + null, + ); + } + } +} diff --git a/src/rule/rule.module.ts b/src/rule/rule.module.ts new file mode 100644 index 0000000..24c3d93 --- /dev/null +++ b/src/rule/rule.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { RuleService } from './rule.service'; +import { RuleController } from './rule.controller'; +import { S3UtilService } from '../utils/S3.service'; +import { UserService } from '../user/user.service'; + +@Module({ + controllers: [RuleController], + providers: [RuleService, S3UtilService, UserService], +}) +export class RuleModule {} diff --git a/src/rule/rule.service.ts b/src/rule/rule.service.ts new file mode 100644 index 0000000..96d016f --- /dev/null +++ b/src/rule/rule.service.ts @@ -0,0 +1,981 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { CreateRuleDto } from './dto/create-rule.dto'; +import { RuleMainEntity } from './domain/rule.main.entity'; +import { RuleSubEntity } from './domain/rule.sub.entity'; +import { RuleInvitationEntity } from './domain/rule.invitation.entity'; +import { UserEntity } from '../user/user.entity'; +import { + DetailMemberDto, + DetailRuleDto, + RulePairDto, +} from './dto/detail.rule.dto'; +import { S3UtilService } from '../utils/S3.service'; +import { GetMemberListDto } from './dto/get-member-list.dto'; +import { UserService } from '../user/user.service'; +import { GetRuleListDto, MemberPairDto } from './dto/get-rule-list.dto'; +import { Like, MoreThan } from 'typeorm'; +import { GetSearchMemberDto } from './dto/get-search-member.dto'; +import { UpdateRuleDto } from './dto/update-rule.dto'; +import { CursorPageOptionsDto } from './dto/cursor-page.options.dto'; +import { CommentEntity } from '../comment/domain/comment.entity'; +import { GetCommentDto } from './dto/get-comment.dto'; +import { CursorPageDto } from './dto/cursor-page.dto'; +import { CursorPageMetaDto } from './dto/cursor-page.meta.dto'; +import { GetSearchMemberAtCreateDto } from './dto/get-search-member-at-create.dto'; +import { UserFollowingEntity } from '../user/user.following.entity'; + +@Injectable() +export class RuleService { + constructor( + private readonly s3Service: S3UtilService, + private readonly userService: UserService, + ) {} + + // [1] 여행 규칙 생성 + async createRule(dto: CreateRuleDto, userId: number): Promise { + try { + // 사용자 검증 + const inviterEntity = await UserEntity.findOneOrFail({ + where: { id: userId }, + }); + if (!inviterEntity) throw new Error('사용자를 찾을 수 없습니다'); + + // -1) main 저장 + const main = new RuleMainEntity(); + main.mainTitle = dto.mainTitle; + await main.save(); + console.log(main); + + dto.rulePairs.sort((a, b) => a.ruleNumber - b.ruleNumber); + + // -2) rule 저장 + for (const pair of dto.rulePairs) { + console.log('현재 저장하는 ruleNumber : ', pair.ruleNumber); + const sub = new RuleSubEntity(); + sub.ruleTitle = pair.ruleTitle; + sub.ruleDetail = pair.ruleDetail; + sub.main = main; + + await sub.save(); + } + + // -3) invitation 저장 + await Promise.all( + dto.membersId.map(async (memberId): Promise => { + const ruleInvitationEntity = new RuleInvitationEntity(); + + const userEntity = await UserEntity.findOne({ + where: { id: memberId }, + }); + if (!userEntity) + throw new NotFoundException( + '멤버로 초대한 회원을 찾을 수 없습니다', + ); + if (userEntity.isQuit == true) + throw new BadRequestException( + '탈퇴한 회원은 멤버로 초대할 수 없습니다', + ); + ruleInvitationEntity.rule = main; + ruleInvitationEntity.member = userEntity; + + await ruleInvitationEntity.save(); + return ruleInvitationEntity; + }), + ); + + // -4) 여행 규칙 글 작성자 정보 저장 + const writerEntity = new RuleInvitationEntity(); + + writerEntity.member = inviterEntity; + writerEntity.rule = main; + await writerEntity.save(); + + return main.id; + } catch (e) { + throw new Error(e.message); + } + } + + // [2] 여행 규칙 상세 페이지 조회 (게시글) + async getDetail(userId: number, ruleId: number): Promise { + const dto = new DetailRuleDto(); + + try { + // 검증1) 사용자가 존재하지 않는 경우 + const user = await UserEntity.findOne({ + where: { id: userId }, + }); + if (!user) throw new Error('사용자를 찾을 수 없습니다'); + + // 검증2) 규칙이 존재하지 않는 경우 + const ruleMain = await RuleMainEntity.findOne({ + where: { id: ruleId }, + relations: { rules: true, invitations: { member: true } }, + }); + if (!ruleMain) throw new Error('규칙을 찾을 수 없습니다'); + + // 검증3) 규칙에 참여하는 사용자인지 체크 + const invitation = await RuleInvitationEntity.findOne({ + where: { member: { id: userId }, rule: { id: ruleId } }, + }); + + const subs: RuleSubEntity[] = await RuleSubEntity.find({ + where: { main: { id: ruleId } }, + }); + const invitations: RuleInvitationEntity[] = + await RuleInvitationEntity.find({ + where: { rule: { id: ruleId } }, + relations: { member: { profileImage: true } }, + }); + + if (!!invitation) { + // -1) 제목 + dto.id = ruleId; + dto.mainTitle = ruleMain.mainTitle; + console.log('dto.id : ', dto.id); + + // -2) 규칙 + const rulePairs = await Promise.all( + subs.map(async (sub): Promise => { + const rulePair = new RulePairDto(); + rulePair.id = sub.id; + rulePair.ruleTitle = sub.ruleTitle; + rulePair.ruleDetail = sub.ruleDetail; + console.log('rulePair.id', rulePair.id); + + return rulePair; + }), + ); + dto.rulePairs = rulePairs.sort((a, b) => a.id - b.id); + + // -3) 멤버 정보 + const detailMembers = await Promise.all( + invitations.map(async (invitation): Promise => { + const detailMember = new DetailMemberDto(); + const memberEntity = invitation.member; + if (memberEntity.isQuit == false) { + detailMember.id = memberEntity.id; + detailMember.name = memberEntity.nickname; + console.log('detailMember.id : ', detailMember.id); + + // 사용자 프로필 이미지 + const image = memberEntity.profileImage; + if (image == null) detailMember.image = null; + else { + const userImageKey = image.imageKey; + detailMember.image = await this.s3Service.getImageUrl( + userImageKey, + ); + } + } + // 탈퇴한 회원인데 ruleInvitationEntity 삭제 안된 경우) + else { + console.log( + '탈퇴한 회원의 ruleInvitationEntity 가 삭제되지 않았습니다', + ); + console.log('탈퇴한 회원의 ID : ', memberEntity.id); + console.log('해당 ruleInvitationEntity ID : ', invitation.id); + detailMember.id = null; + detailMember.name = null; + detailMember.image = null; + } + + return detailMember; + }), + ); + dto.detailMembers = detailMembers.sort((a, b) => a.id - b.id); + + return dto; + } else { + throw new Error('여행 규칙에 참여하는 사용자가 아닙니다'); + } + } catch (e) { + console.log('게시글 조회에 실패하였습니다'); + throw new Error(e.message); + } + } + + // [3] 여행 규칙 상세 페이지 조회 (댓글) - 페이지네이션 + async getComment( + cursorPageOptionsDto: CursorPageOptionsDto, + ruleId: number, + userId: number, + ): Promise> { + try { + // 검증1) 사용자가 존재하지 않는 경우 + const user = await UserEntity.findOne({ + where: { id: userId }, + }); + if (!user) throw new Error('사용자를 찾을 수 없습니다'); + + // 검증2) 규칙이 존재하지 않는 경우 + const ruleMain = await RuleMainEntity.findOne({ + where: { id: ruleId }, + }); + if (!ruleMain) throw new Error('규칙을 찾을 수 없습니다'); + + // 검증3) 규칙에 참여하는 사용자가 아닌 경우 + const invitation = await RuleInvitationEntity.findOne({ + where: { member: { id: userId }, rule: { id: ruleId } }, + }); + if (!invitation) throw new Error('사용자가 참여하는 규칙이 아닙니다'); + + console.log('--- 검증 완료 ---'); + + // (1) 데이터 조회 + const cursorId: number = cursorPageOptionsDto.cursorId; + + const [comments, total] = await CommentEntity.findAndCount({ + take: cursorPageOptionsDto.take, + where: { + rule: { id: ruleId }, + id: cursorId ? MoreThan(cursorId) : null, + }, + relations: { user: { profileImage: true } }, + order: { + id: 'ASC' as any, + }, + }); + + const result = await Promise.all( + comments.map(async (comment) => { + const getCommentDto = new GetCommentDto(); + + getCommentDto.id = comment.id; + getCommentDto.content = comment.content; + getCommentDto.updated = comment.updated; + + // 댓글 작성자 정보 + // 탈퇴한 사용자가 작성한 댓글도 표시 + // -> 댓글 작성자 (user) 존재 여부 확인 + const writerEntity = comment.user; + if (writerEntity.isQuit == false) { + getCommentDto.writerId = comment.user.id; + getCommentDto.name = comment.user.nickname; + + // 사용자 프로필 이미지 + const image = comment.user.profileImage; + if (image == null) getCommentDto.image = null; + else { + const userImageKey = image.imageKey; + getCommentDto.image = await this.s3Service.getImageUrl( + userImageKey, + ); + } + } + // 댓글 작성자가 탈퇴한 사용자인 경우 + else { + console.log('탈퇴한 회원이 작성한 댓글 입니다'); + getCommentDto.writerId = null; + getCommentDto.name = null; + getCommentDto.image = null; + } + + return getCommentDto; + }), + ); + + // (2) 페이징 및 정렬 기준 설정 + let hasNextData = true; + let cursor: number; + + const takePerScroll = cursorPageOptionsDto.take; + const isLastScroll = total <= takePerScroll; + const lastDataPerScroll = comments[comments.length - 1]; + + if (isLastScroll) { + hasNextData = false; + cursor = null; + } else { + cursor = lastDataPerScroll.id; + } + + const cursorPageMetaDto = new CursorPageMetaDto({ + cursorPageOptionsDto, + total, + hasNextData, + cursor, + }); + + return new CursorPageDto(result, cursorPageMetaDto); + } catch (e) { + throw new Error(e.message); + } + } + + // [4] 여행 규칙 나가기 + async deleteInvitation( + ruleId: number, + userId: number, + ): Promise { + try { + // 검증1) 사용자가 존재하지 않는 경우 + const user = await UserEntity.findOne({ + where: { id: userId }, + }); + if (!user) throw new Error('사용자를 찾을 수 없습니다'); + + // 검증2) 규칙이 존재하지 않는 경우 + const ruleMain = await RuleMainEntity.findOne({ + where: { id: ruleId }, + }); + if (!ruleMain) throw new Error('규칙을 찾을 수 없습니다'); + + // 검증3) 규칙에 참여하는 사용자가 아닌 경우 + const invitation = await RuleInvitationEntity.findOne({ + where: { member: { id: userId }, rule: { id: ruleId } }, + }); + if (!!invitation) { + return invitation.softRemove(); + } else throw new Error('사용자가 참여하는 규칙이 아닙니다'); + } catch (e) { + console.log('여행 규칙 나가기 실패'); + throw new Error(e.message); + } + } + + // [4] 여행 규칙 멤버 리스트 조회 + async getMemberList( + userId: number, + ruleId: number, + ): Promise { + try { + // 검증1) 사용자가 존재하지 않는 경우 + const user = await UserEntity.findOne({ + where: { id: userId }, + }); + if (!user) throw new Error('사용자를 찾을 수 없습니다'); + + // 검증2) 규칙이 존재하지 않는 경우 + const ruleMain = await RuleMainEntity.findOne({ + where: { id: ruleId }, + }); + if (!ruleMain) throw new Error('규칙을 찾을 수 없습니다'); + + // 검증3) 규칙에 참여하는 사용자가 아닌 경우 + const invitation = await RuleInvitationEntity.findOne({ + where: { member: { id: userId }, rule: { id: ruleId } }, + }); + + if (!!invitation) { + const invitationsList: RuleInvitationEntity[] = + await RuleInvitationEntity.find({ + where: { rule: { id: ruleId } }, + relations: { member: true }, + }); + + const membersList: GetMemberListDto[] = await Promise.all( + invitationsList.map(async (invitation): Promise => { + const memberEntity: UserEntity = invitation.member; + const memberDto: GetMemberListDto = new GetMemberListDto(); + + console.log('memberEntity : ', memberEntity); + memberDto.id = memberEntity.id; + memberDto.name = memberEntity.nickname; + memberDto.email = memberEntity.email; + memberDto.introduction = memberEntity.introduction; + + // 사용자 프로필 이미지 + const image = await this.userService.getProfileImage( + memberEntity.id, + ); + if (image == null) memberDto.image = null; + else { + const userImageKey = image.imageKey; + memberDto.image = await this.s3Service.getImageUrl(userImageKey); + } + return memberDto; + }), + ); + const sortedList = membersList.sort((a, b) => a.id - b.id); + return sortedList; + } else throw new Error('사용자가 참여하는 규칙이 아닙니다'); + } catch (e) { + throw new Error(e.message); + } + } + + // [5] 여행 규칙 전체 리스트 조회 + async getRuleList(userId: number): Promise { + try { + // 검증) 사용자가 존재하지 않는 경우 + const user = await UserEntity.findOne({ + where: { + id: userId, + }, + relations: { + ruleParticipate: { + rule: { + invitations: { + member: { + profileImage: true, + }, + }, + }, + }, + }, + }); + console.log('현재 로그인한 사용자 : ', user.id); + if (!user) throw new Error('사용자를 찾을 수 없습니다'); + + const invitationEntities = await RuleInvitationEntity.find({ + where: { member: { id: userId } }, + relations: { + rule: { + invitations: true, + }, + }, + }); + + if (!!invitationEntities) { + const getRuleListDtos = await Promise.all( + invitationEntities.map( + async ( + invitation: RuleInvitationEntity, + ): Promise => { + const ruleListDto: GetRuleListDto = new GetRuleListDto(); + const ruleId = invitation.rule.id; + const ruleMain = invitation.rule; + + ruleListDto.id = ruleMain.id; + ruleListDto.title = ruleMain.mainTitle; + ruleListDto.updated = ruleMain.updated; + ruleListDto.memberCnt = ruleMain.invitations.length; + ruleListDto.memberPairs = await this.getMemberPairs(ruleId); + + return ruleListDto; + }, + ), + ); + + const sortedGetRuleListDtos = getRuleListDtos.sort( + (a, b) => + new Date(b.updated).getTime() - new Date(a.updated).getTime(), + ); + + return sortedGetRuleListDtos; + } + } catch (e) { + console.log('참여하는 여행 규칙이 없습니다'); + throw new Error(e.message); + } + } + + async getMemberPairs(ruleId: number): Promise { + const invitations = await RuleInvitationEntity.find({ + where: { rule: { id: ruleId } }, + relations: { + member: { + profileImage: true, + }, + }, + }); + + const result: MemberPairDto[] = await Promise.all( + invitations.map(async (invitation): Promise => { + const memberPair = new MemberPairDto(); + const user: UserEntity = invitation.member; + + console.log('user.id : ', user.id); + memberPair.id = user.id; + memberPair.name = user.nickname; + + // 사용자 프로필 이미지 + const image = user.profileImage; + if (image == null) memberPair.image = null; + else { + const userImageKey = image.imageKey; + memberPair.image = await this.s3Service.getImageUrl(userImageKey); + } + return memberPair; + }), + ); + return result; + } + + // [6] 여행 규칙 참여 멤버로 초대할 메이트 검색 결과 - 무한 스크롤 + // 여행 규칙 생성 / 여행 규칙 수정 분리 + // case1. 여행 규칙 생성 + async getSearchMemberAtCreate( + cursorPageOptionsDto: CursorPageOptionsDto, + userId: number, + searchTerm: string, + ): Promise> { + try { + // 검증1) 사용자가 존재하지 않는 경우 + const user = await UserEntity.findOne({ + where: { + id: userId, + }, + }); + if (!user) throw new Error('사용자를 찾을 수 없습니다'); + + // (1) cursorId 설정 + let cursorId = 0; + console.log('cursorPageOptionsDto : ', cursorPageOptionsDto); + + // -1) 처음 요청인 경우 + if (cursorPageOptionsDto.cursorId == 0) { + const newUser = await UserEntity.find({ + order: { + id: 'DESC', // 가장 최근에 가입한 유저 + }, + take: 1, + }); + cursorId = newUser[0].id + 1; + + console.log('cursorPageOptionsDto.cursorId == 0 로 인식'); + console.log('cursor: ', cursorId); + // -2) 처음 요청이 아닌 경우 + } else { + cursorId = cursorPageOptionsDto.cursorId; + console.log('cursorPageOptionsDto.cursorId != 0 로 인식'); + } + console.log('cursor: ', cursorId); + + // (2) 데이터 조회 + // 검색 결과에 해당하는 값 찾기 + // [검색 조건] + // 검색 기준 UserFollowingEntity : 검색 결과로 나와야 하는 사용자 (searchTerm 에 해당하는) + // 해당 결과값을 nickName 에 포함하고 있는 사용자 찾기 + // 본인이 팔로우 하는 사용자 중에서만 검색이 가능하도록 (본인은 자동으로 검색 결과에서 제외) + + console.log('검색 값: ', searchTerm); + + // 조건에 맞는 유저 검색해서 [] 에 담고 + // 해당 리스트에서 UserEntity 의 id, cursorId 이용해서 가져오기 + + // 1번 검색 조건) searchTerm 을 name 이나 nickname 에 포함하고 있는 + // 2번 검색 조건) 유저가 팔로우하는 유저 + // userFollowingEntity) user: 로그인한 유저, followUser: 유저가 팔로우하는 유저 + let resultFollowingEntities = await UserFollowingEntity.find({ + where: { + user: { id: userId }, + followUser: { nickname: Like(`%${searchTerm}%`) }, + }, + relations: { + followUser: { profileImage: true }, + }, + order: { + followUser: { id: 'DESC' }, + }, + }); + console.log('resultFollowingEntities', resultFollowingEntities); + + // 3번 검색 조건) 탈퇴 여부 확인 + resultFollowingEntities = resultFollowingEntities.filter( + (userFollowingEntity) => userFollowingEntity.followUser.isQuit == false, + ); + for (const userFollowingEntity of resultFollowingEntities) { + console.log( + 'isQuit == false : ', + userFollowingEntity.followUser.isQuit, + ); + } + + const total = resultFollowingEntities.length; + + // 4번 검색 조건) id 가 cursorId 보다 작은 + // 해당 요소보다 작은 요소들만 필터링 + for (const userFollowingEntity of resultFollowingEntities) { + console.log( + 'userFollowingEntity.followUser.id : ', + userFollowingEntity.followUser.id, + ); + } + + resultFollowingEntities = resultFollowingEntities.filter( + (userFollowingEntity) => userFollowingEntity.followUser.id < cursorId, + ); + + // take 초기값 설정 + console.log('cursorPageOptionsDto.take : ', cursorPageOptionsDto.take); + if (cursorPageOptionsDto.take == 0) { + cursorPageOptionsDto.take = 5; + } + const results = resultFollowingEntities.slice( + 0, + cursorPageOptionsDto.take, + ); + + console.log('results (UserFollowingEntity[]) : ', results); + + const searchResult = await Promise.all( + results.map(async (result) => { + const dtoAtCreate: GetSearchMemberAtCreateDto = + new GetSearchMemberAtCreateDto(); + const follower = result.followUser; + + dtoAtCreate.id = follower.id; + dtoAtCreate.name = follower.nickname; + dtoAtCreate.email = follower.email; + dtoAtCreate.introduction = follower.introduction; + + // 사용자 프로필 이미지 + const image = follower.profileImage; + if (image == null) dtoAtCreate.image = null; + else { + const followerImageKey = image.imageKey; + dtoAtCreate.image = await this.s3Service.getImageUrl( + followerImageKey, + ); + } + return dtoAtCreate; + }), + ); + + console.log('searchResult : ', searchResult); + + // (3) 페이징 및 정렬 기준 설정 + let hasNextData = true; + let cursor: number; + + const takePerScroll = cursorPageOptionsDto.take; + console.log('takePerScroll : ', takePerScroll); + const isLastScroll = total <= takePerScroll; + console.log('isLastScroll : ', isLastScroll); + console.log('total : ', total); + const lastDataPerScroll = searchResult[searchResult.length - 1]; + + if (isLastScroll) { + hasNextData = false; + cursor = null; + } else { + cursor = lastDataPerScroll.id; + } + + const cursorPageMetaDto = new CursorPageMetaDto({ + cursorPageOptionsDto, + total, + hasNextData, + cursor, + }); + + return new CursorPageDto(searchResult, cursorPageMetaDto); + } catch (e) { + throw new Error(e.message); + } + } + + // [6-2] case2. 여행 규칙 수정 + async getSearchMemberAtUpdate( + cursorPageOptionsDto: CursorPageOptionsDto, + userId: number, + ruleId: number, + searchTerm: string, + ): Promise> { + try { + // 검증1) 사용자가 존재하지 않는 경우 + const user = await UserEntity.findOne({ + where: { + id: userId, + }, + }); + if (!user) throw new Error('사용자를 찾을 수 없습니다'); + // 검증2) 규칙이 존재하지 않는 경우 + const rule = await RuleMainEntity.findOne({ + where: { id: ruleId }, + relations: { rules: true, invitations: { member: true } }, + }); + if (!rule) throw new Error('규칙을 찾을 수 없습니다'); + // 검증3) 규칙에 참여하는 사용자인지 체크 + const invitation = await RuleInvitationEntity.findOne({ + where: { member: { id: userId }, rule: { id: ruleId } }, + }); + + if (!invitation) throw new Error('규칙에 참여하지 않는 사용자 입니다'); + + // (1) cursorId 설정 + let cursorId = 0; + console.log('cursorPageOptionsDto : ', cursorPageOptionsDto); + + // -1) 처음 요청인 경우 + if (cursorPageOptionsDto.cursorId == 0) { + const newUser = await UserEntity.find({ + order: { + id: 'DESC', // 가장 최근에 가입한 유저 + }, + take: 1, + }); + cursorId = newUser[0].id + 1; + + console.log('cursorPageOptionsDto.cursorId == 0 로 인식'); + console.log('cursor: ', cursorId); + // -2) 처음 요청이 아닌 경우 + } else { + cursorId = cursorPageOptionsDto.cursorId; + console.log('cursorPageOptionsDto.cursorId != 0 로 인식'); + } + console.log('cursor: ', cursorId); + + // (2) 데이터 조회 + // 검색 결과에 해당하는 값 찾기 + // [검색 조건] + // 검색 기준 UserFollowingEntity : 검색 결과로 나와야 하는 사용자 (searchTerm 에 해당하는) + // 해당 결과값을 nickName 에 포함하고 있는 사용자 찾기 + // 본인이 팔로우 하는 사용자 중에서만 검색이 가능하도록 (본인은 자동으로 검색 결과에서 제외) + + console.log('검색 값: ', searchTerm); + + // 조건에 맞는 유저 검색해서 [] 에 담고 + // 해당 리스트에서 UserEntity 의 id, cursorId 이용해서 가져오기 + + // 1번 검색 조건) searchTerm 을 name 이나 nickname 에 포함하고 있는 + // 2번 검색 조건) 유저가 팔로우하는 유저 + // userFollowingEntity) user: 로그인한 유저, followUser: 유저가 팔로우하는 유저 + let resultFollowingEntities = await UserFollowingEntity.find({ + where: { + user: { id: userId }, + followUser: { nickname: Like(`%${searchTerm}%`) }, + }, + relations: { + followUser: { profileImage: true, ruleParticipate: { rule: true } }, + }, + order: { + followUser: { id: 'DESC' }, + }, + }); + console.log('resultFollowingEntities', resultFollowingEntities); + + // 3번 검색 조건) 탈퇴 여부 확인 + resultFollowingEntities = resultFollowingEntities.filter( + (userFollowingEntity) => userFollowingEntity.followUser.isQuit == false, + ); + for (const userFollowingEntity of resultFollowingEntities) { + console.log( + 'isQuit == false : ', + userFollowingEntity.followUser.isQuit, + ); + } + + const total = resultFollowingEntities.length; + + // 4번 검색 조건) id 가 cursorId 보다 작은 + // 해당 요소보다 작은 요소들만 필터링 + for (const userFollowingEntity of resultFollowingEntities) { + console.log( + 'userFollowingEntity.followUser.id : ', + userFollowingEntity.followUser.id, + ); + } + + resultFollowingEntities = resultFollowingEntities.filter( + (userFollowingEntity) => userFollowingEntity.followUser.id < cursorId, + ); + + // take 초기값 설정 + console.log('cursorPageOptionsDto.take : ', cursorPageOptionsDto.take); + if (cursorPageOptionsDto.take == 0) { + cursorPageOptionsDto.take = 5; + } + const results = resultFollowingEntities.slice( + 0, + cursorPageOptionsDto.take, + ); + + console.log('results (UserFollowingEntity[]) : ', results); + + // dto 데이터 넣기 + const searchResult = await Promise.all( + results.map(async (result) => { + const dto: GetSearchMemberDto = new GetSearchMemberDto(); + const follower = result.followUser; + + dto.id = follower.id; + dto.name = follower.nickname; + dto.email = follower.email; + dto.introduction = follower.introduction; + // 이미 여행 규칙에 참여하는 멤버인지 여부 + dto.isInvited = await this.userService.checkAlreadyMember( + follower.id, + ruleId, + ); + + // 사용자 프로필 이미지 + const image = follower.profileImage; + if (image == null) dto.image = null; + else { + const followerImageKey = image.imageKey; + dto.image = await this.s3Service.getImageUrl(followerImageKey); + } + return dto; + }), + ); + + console.log('searchResult : ', searchResult); + + // (3) 페이징 및 정렬 기준 설정 + let hasNextData = true; + let cursor: number; + + const takePerScroll = cursorPageOptionsDto.take; + const isLastScroll = total <= takePerScroll; + const lastDataPerScroll = searchResult[searchResult.length - 1]; + + if (isLastScroll) { + hasNextData = false; + cursor = null; + } else { + cursor = lastDataPerScroll.id; + } + + const cursorPageMetaDto = new CursorPageMetaDto({ + cursorPageOptionsDto, + total, + hasNextData, + cursor, + }); + + return new CursorPageDto(searchResult, cursorPageMetaDto); + } catch (e) { + throw new Error(e.message); + } + } + + // [7] 여행 규칙 수정 + async updateRule( + updateRuleDto: UpdateRuleDto, + userId: number, + ruleId: number, + ): Promise { + try { + // 검증1) 사용자가 존재하지 않는 경우 + const user = await UserEntity.findOne({ + where: { id: userId }, + }); + if (!user) throw new Error('사용자를 찾을 수 없습니다'); + + // 검증2) 규칙이 존재하지 않는 경우 + const rule = await RuleMainEntity.findOne({ + where: { id: ruleId }, + relations: { rules: true, invitations: { member: true } }, + }); + if (!rule) throw new Error('규칙을 찾을 수 없습니다'); + + // 검증3) 규칙에 참여하는 사용자인지 체크 + const invitation = await RuleInvitationEntity.findOne({ + where: { member: { id: userId }, rule: { id: ruleId } }, + }); + // -> 규칙에 참여하는 사용자인 경우 + if (!!invitation) { + updateRuleDto.rulePairs.sort((a, b) => a.ruleNumber - b.ruleNumber); + + rule.mainTitle = updateRuleDto.mainTitle; + await rule.save(); + + // (1) [상세 규칙 수정] + // 기존 세부 규칙 정보 리스트 + const subs = rule.rules; + + // 새로운 세부 규칙 리스트 + const updateSubsList = updateRuleDto.rulePairs; + updateSubsList.sort((a, b) => a.ruleNumber - b.ruleNumber); + + // case1) 규칙 삭제 + for (const sub of subs) { + let isDeleteSub = true; + for (const updateSub of updateSubsList) { + if (sub.id == updateSub.id) { + isDeleteSub = false; + break; + } + } + if (isDeleteSub) { + await sub.softRemove(); + console.log('삭제하는 sub 규칙 : ', sub.id); + } + } + + // case2) 규칙 수정 및 규칙 추가 + for (const updateSub of updateSubsList) { + // case1) 새로운 규칙 + if (!updateSub.id) { + const newSub = new RuleSubEntity(); + newSub.main = rule; + newSub.ruleTitle = updateSub.ruleTitle; + newSub.ruleDetail = updateSub.ruleDetail; + + await newSub.save(); + console.log('새로 저장하는 sub 규칙 : ', newSub.id); + } + // case2) 수정 규칙 + else { + const oldSub = await RuleSubEntity.findOne({ + where: { id: updateSub.id }, + }); + oldSub.ruleTitle = updateSub.ruleTitle; + oldSub.ruleDetail = updateSub.ruleDetail; + + await oldSub.save(); + console.log('수정하는 규칙 ID : ', oldSub); + } + } + + // (2) [여행 규칙 멤버 수정] + // 기존 멤버 초대 리스트 + const oldInvitations = await RuleInvitationEntity.find({ + where: { rule: { id: ruleId } }, + relations: { member: true }, + }); + // 수정된 멤버 ID 리스트 + const updateMemberIds = updateRuleDto.membersId; + + // case1) 멤버 삭제 + for (const invitation of oldInvitations) { + const member = invitation.member; + let isDeleteMember = true; + + // (예외 상황) 현재 로그인한 사용자 + if (member.id == userId) break; + + for (const updateMemberId of updateMemberIds) { + if (member.id == updateMemberId) { + isDeleteMember = false; + break; + } + } + if (isDeleteMember) { + await invitation.softRemove(); + console.log('삭제하는 멤버 ID : ', invitation.id); + } + } + + // case2) 멤버 추가 + for (const updateMemberId of updateMemberIds) { + await UserEntity.findExistUser(updateMemberId); + + let isPostMember = true; + + for (const oldInvitation of oldInvitations) { + const oldMember = oldInvitation.member; + if (oldMember.id == updateMemberId) { + isPostMember = false; + break; + } + } + + if (isPostMember) { + const newInvitation = new RuleInvitationEntity(); + + newInvitation.member = await UserEntity.findExistUser( + updateMemberId, + ); + newInvitation.rule = rule; + + await newInvitation.save(); + console.log('새로 초대한 멤버 ID : ', updateMemberId); + } + } + console.log('--여행 규칙 수정이 완료되었습니다--'); + return rule.id; + } else throw new Error('사용자가 참여하는 규칙이 아닙니다'); // -> 여행 규칙에 참여하지 않는 경우 + } catch (e) { + console.log('여행 규칙 수정 실패'); + throw new Error(e.message); + } + } +} diff --git a/src/schedule/dtos/find-monthly-schedule.dto.ts b/src/schedule/dtos/find-monthly-schedule.dto.ts new file mode 100644 index 0000000..b0c63fd --- /dev/null +++ b/src/schedule/dtos/find-monthly-schedule.dto.ts @@ -0,0 +1,8 @@ +import { IsInt } from 'class-validator'; + +export class FindMonthlyScheduleDto { + @IsInt() + year: number; + @IsInt() + month: number; +} diff --git a/src/schedule/dtos/update-schedule-dto.ts b/src/schedule/dtos/update-schedule-dto.ts new file mode 100644 index 0000000..8bd60b7 --- /dev/null +++ b/src/schedule/dtos/update-schedule-dto.ts @@ -0,0 +1,20 @@ +// Update-schedule.dto.ts +import { IsString, IsNumber, IsOptional } from 'class-validator'; + +export class UpdateScheduleDto { + @IsString() + @IsOptional() + title: string; + + @IsString() + @IsOptional() + location: string; + + @IsOptional() + @IsNumber() + latitude: number; + + @IsOptional() + @IsNumber() + longitude: number; +} diff --git a/src/schedule/schedule.controller.ts b/src/schedule/schedule.controller.ts index 8eb1a3f..16dec62 100644 --- a/src/schedule/schedule.controller.ts +++ b/src/schedule/schedule.controller.ts @@ -1,7 +1,47 @@ -import { Controller } from '@nestjs/common'; +import { Body, Controller, Put, Param, Req, UseGuards } from '@nestjs/common'; +import { ApiOkResponse, ApiOperation } from '@nestjs/swagger'; +import { Request } from 'express'; +import { UserGuard } from 'src/user/user.guard'; import { ScheduleService } from './schedule.service'; +import { UpdateScheduleDto } from './dtos/update-schedule-dto'; @Controller('schedule') export class ScheduleController { constructor(private readonly scheduleService: ScheduleService) {} + + @ApiOperation({ + summary: '일정 작성하기', + description: '제목과 위치를 작성합니다.', + }) + @ApiOkResponse({ + description: '성공 ', + }) + @UseGuards(UserGuard) + @Put('update/:scheduleId') + async updateSchedule( + @Req() req: Request, + @Param('scheduleId') scheduleId: number, + @Body() body: UpdateScheduleDto, + ) { + const result = await this.scheduleService.updateSchedule(scheduleId, body); + return result; + } + + @ApiOperation({ + summary: '일정 삭제하기', + description: '제목과 위치를 삭제합니다.', + }) + @ApiOkResponse({ + description: '성공 ', + }) + @UseGuards(UserGuard) + @Put('reset/:scheduleId') + async deleteSchedule( + @Param('scheduleId') scheduleId: number, + @Req() req: Request, + ) { + const user = req.user; + const result = await this.scheduleService.resetSchedule(user, scheduleId); + return result; + } } diff --git a/src/schedule/schedule.detail.entity.ts b/src/schedule/schedule.detail.entity.ts deleted file mode 100644 index 6f48087..0000000 --- a/src/schedule/schedule.detail.entity.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - BaseEntity, Column, - CreateDateColumn, - DeleteDateColumn, - Entity, JoinColumn, ManyToOne, - PrimaryGeneratedColumn, - UpdateDateColumn, -} from 'typeorm'; -import { ScheduleEntity } from './schedule.entity'; - -@Entity() -export class ScheduleDetailEntity extends BaseEntity { - @PrimaryGeneratedColumn() - id: number; - - @JoinColumn() - @ManyToOne(() => ScheduleEntity, (schedule) => schedule.scheduleDetails) - schedule: ScheduleEntity; - - @Column() - name: string; - - @Column() - isDone: boolean; - - @CreateDateColumn() - created: Date; - - @UpdateDateColumn() - updated: Date; - - @DeleteDateColumn() - deleted: Date; -} \ No newline at end of file diff --git a/src/schedule/schedule.entity.ts b/src/schedule/schedule.entity.ts index ccae4be..473d247 100644 --- a/src/schedule/schedule.entity.ts +++ b/src/schedule/schedule.entity.ts @@ -1,34 +1,53 @@ import { - BaseEntity, Column, + BaseEntity, + Column, CreateDateColumn, + UpdateDateColumn, DeleteDateColumn, - Entity, JoinColumn, ManyToOne, OneToMany, + Entity, + ManyToOne, + OneToMany, PrimaryGeneratedColumn, - UpdateDateColumn, + OneToOne, + Between, } from 'typeorm'; -import { ScheduleGroupEntity } from './schedule.group.entity'; -import { ScheduleDetailEntity } from './schedule.detail.entity'; +import { NotFoundException } from '@nestjs/common'; +import { BaseResponse } from 'src/response/response.status'; +import { DetailScheduleEntity } from '../detail-schedule/detail-schedule.entity'; +import { LocationEntity } from 'src/location/location.entity'; +import { DiaryEntity } from 'src/diary/models/diary.entity'; +import { JourneyEntity } from 'src/journey/model/journey.entity'; +import { MonthInfoDto } from 'src/map/month-info.dto'; @Entity() export class ScheduleEntity extends BaseEntity { @PrimaryGeneratedColumn() id: number; - @JoinColumn() - @ManyToOne(() => ScheduleGroupEntity, (scheduleGroup) => scheduleGroup.schedules) - scheduleGroup: ScheduleGroupEntity; - - @OneToMany(() => ScheduleDetailEntity, (scheduleDetail) => scheduleDetail.schedule) - scheduleDetails: ScheduleDetailEntity[]; - @Column({ type: 'date' }) date: Date; - @Column() + @Column({ nullable: true }) title: string; - @Column() - participants: string; + @ManyToOne(() => LocationEntity, (location) => location.schedules, { + nullable: true, + }) + location: LocationEntity | null; + + @ManyToOne(() => JourneyEntity, (journey) => journey.schedules, { + onDelete: 'CASCADE', + }) + journey: JourneyEntity; + + @OneToMany( + () => DetailScheduleEntity, + (detailSchedule) => detailSchedule.schedule, + ) + detailSchedules: DetailScheduleEntity[]; + + @OneToOne(() => DiaryEntity, (diary) => diary.schedule) + diary: DiaryEntity; @CreateDateColumn() created: Date; @@ -38,4 +57,104 @@ export class ScheduleEntity extends BaseEntity { @DeleteDateColumn() deleted: Date; -} \ No newline at end of file + + //일정 작성하기 + static async createSchedule(journey: JourneyEntity, currentDate) { + const schedule = new ScheduleEntity(); + schedule.date = currentDate; + schedule.journey = journey; + return await schedule.save(); + } + + //일정 작성하기 : title + static async updateScheduleTitle( + schedule: ScheduleEntity, + updateScheduleDto, + ) { + schedule.title = updateScheduleDto.title; + return await schedule.save(); + } + + //일정 작성하기 : location + static async updateScheduleLocation( + schedule: ScheduleEntity, + location: LocationEntity, + ) { + schedule.location = location; + return await schedule.save(); + } + + //일정 삭제하기 - 여정 삭제했을때 + static async deleteSchedule(schedule) { + return await ScheduleEntity.remove(schedule); + } + + //일정 리셋하기 - 제목, 위치 + static async resetSchedule(schedule: ScheduleEntity) { + schedule.title = ''; + schedule.location = null; + await schedule.save(); + } + + //일정 조회하기 + static async findExistSchedule(scheduleId): Promise { + const schedule = await ScheduleEntity.findOne({ + where: { id: scheduleId }, + relations: ['location'], + }); + if (!schedule) { + throw new NotFoundException(BaseResponse.SCHEDULE_NOT_FOUND); + } + return schedule; + } + + static async findExistLocation(scheduleId): Promise { + const schedule = await ScheduleEntity.findOne({ + where: { id: scheduleId }, + relations: ['location'], + }); + return schedule.location; + } + + static async findExistLocations(location: LocationEntity) { + const existLocation = await ScheduleEntity.find({ + where: { + location: location, + }, + relations: ['location'], + }); + return existLocation; + } + + //journeyId로 일정 조회하기 + static async findExistSchedulesByJourneyId( + journeyId: number, + ): Promise { + const schedules = await ScheduleEntity.find({ + where: { journey: { id: journeyId } }, + relations: ['location'], + }); + return schedules; + } + + // 월별 일정 조회하기 + static async findMonthlySchedule( + journeyId, + dates: MonthInfoDto, + ): Promise { + const firstDate = new Date(dates.year, dates.month - 1, 1); + const lastDate = new Date(dates.year, dates.month, 0); + console.log('startDate : ', firstDate, 'lastDate', lastDate); + const monthlySchedule = await ScheduleEntity.find({ + where: { + journey: { id: journeyId }, + date: Between(firstDate, lastDate), + }, + relations: ['location'], + }); + for (const schedule of monthlySchedule) { + console.log(schedule.date); + } + return monthlySchedule; + } +} diff --git a/src/schedule/schedule.group.entity.ts b/src/schedule/schedule.group.entity.ts deleted file mode 100644 index 004a5e7..0000000 --- a/src/schedule/schedule.group.entity.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { - BaseEntity, - CreateDateColumn, - DeleteDateColumn, - Entity, JoinColumn, ManyToOne, OneToMany, - PrimaryGeneratedColumn, - UpdateDateColumn, -} from 'typeorm'; -import { ScheduleEntity } from './schedule.entity'; -import { UserEntity } from '../user/user.entity'; - -@Entity() -export class ScheduleGroupEntity extends BaseEntity { - @PrimaryGeneratedColumn() - id: number; - - @OneToMany(() => ScheduleEntity, (schedule) => schedule.scheduleGroup) - schedules: ScheduleEntity[]; - - @JoinColumn() - @ManyToOne(() => UserEntity) - user: UserEntity; - - @CreateDateColumn() - created: Date; - - @UpdateDateColumn() - updated: Date; - - @DeleteDateColumn() - deleted: Date; -} \ No newline at end of file diff --git a/src/schedule/schedule.service.ts b/src/schedule/schedule.service.ts index 2e22997..454ce80 100644 --- a/src/schedule/schedule.service.ts +++ b/src/schedule/schedule.service.ts @@ -1,4 +1,68 @@ import { Injectable } from '@nestjs/common'; +import { response } from 'src/response/response'; +import { BaseResponse } from 'src/response/response.status'; +import { LocationEntity } from 'src/location/location.entity'; +import { ScheduleEntity } from './schedule.entity'; +import { UserEntity } from 'src/user/user.entity'; +import { UpdateScheduleDto } from './dtos/update-schedule-dto'; @Injectable() -export class ScheduleService {} +export class ScheduleService { + //일정 작성하기 + async updateSchedule(scheduleId, updateScheduleDto: UpdateScheduleDto) { + const schedule = await ScheduleEntity.findExistSchedule(scheduleId); + + if (!updateScheduleDto.latitude || !updateScheduleDto.longitude) { + await ScheduleEntity.updateScheduleTitle(schedule, updateScheduleDto); + } else if (!updateScheduleDto.title) { + await this.updateScheduleLocation(schedule, updateScheduleDto); + } else { + await this.updateScheduleLocation(schedule, updateScheduleDto); + await ScheduleEntity.updateScheduleTitle(schedule, updateScheduleDto); + } + + return response(BaseResponse.SCHEDULE_UPDATED); + } + + async updateScheduleLocation(schedule, updateScheduleDto) { + const existLocation = await LocationEntity.findExistLocation( + updateScheduleDto, + ); + if (existLocation) { + await ScheduleEntity.updateScheduleLocation(schedule, existLocation); + } else if (schedule.location) { + const location = LocationEntity.updateLocation( + schedule.location, + updateScheduleDto, + ); + console.log(location); + } else { + const location = await LocationEntity.createLocation(updateScheduleDto); + await ScheduleEntity.updateScheduleLocation(schedule, location); + console.log(location); + } + } + + async resetSchedule(user, scheduleId) { + await UserEntity.findExistUser(user.id); + const schedule = await ScheduleEntity.findExistSchedule(scheduleId); + + // 스케줄이 위치를 가지고 있는지 확인 + if (schedule.location) { + // 해당 위치가 다른 스케줄에서 사용되고 있는지 확인 + const existLocation = await ScheduleEntity.findExistLocations( + schedule.location, + ); + console.log(existLocation); + if (!existLocation) { + // 다른 스케줄에서 사용되고 있지 않으면 해당 위치 삭제 + console.log('삭제할', schedule.location); + await LocationEntity.deleteLocation(schedule.location); + } + } + + // 스케줄 초기화 + await ScheduleEntity.resetSchedule(schedule); + return response(BaseResponse.DELETE_SCHEDULE_SUCCESS); + } +} diff --git a/src/search/dto/get-search-main.dto.ts b/src/search/dto/get-search-main.dto.ts new file mode 100644 index 0000000..9aa5e3e --- /dev/null +++ b/src/search/dto/get-search-main.dto.ts @@ -0,0 +1,7 @@ +// get-search-main.dto.ts + +import { SignatureCoverDto } from './signature-cover.dto'; + +export class GetSearchMainDto { + covers: SignatureCoverDto[]; +} diff --git a/src/search/dto/signature-cover.dto.ts b/src/search/dto/signature-cover.dto.ts new file mode 100644 index 0000000..6112216 --- /dev/null +++ b/src/search/dto/signature-cover.dto.ts @@ -0,0 +1,11 @@ +// signature-cover.dto.ts + +export class SignatureCoverDto { + _id: number; + title: string; + image: string; // 시그니처 첫 번째 페이지 사진 + userName: string; // 유저 닉네임 + userImage: string; // 유저 프로필 사진 + date: string; + liked: number; +} diff --git a/src/search/search.controller.ts b/src/search/search.controller.ts new file mode 100644 index 0000000..80e5074 --- /dev/null +++ b/src/search/search.controller.ts @@ -0,0 +1,98 @@ +// search.controller.ts + +import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common'; +import { ResponseDto } from '../response/response.dto'; +import { GetSearchMainDto } from './dto/get-search-main.dto'; +import { ResponseCode } from '../response/response-code.enum'; +import { SignatureCoverDto } from './dto/signature-cover.dto'; +import { SearchService } from './search.service'; +import { Request } from 'express'; +import { OptionalUserGuard } from '../user/optional.user.guard'; + +@Controller('search') +export class SearchController { + constructor(private readonly searchService: SearchService) {} + + @Get('/hot') // 팀색탭 메인: 인기 급상승 시그니처 + async getSearchHotSignatures(): Promise> { + try { + const getHotSignaturesDto: GetSearchMainDto = new GetSearchMainDto(); + + // 인기 급상승 시그니처 가져오기 + getHotSignaturesDto.covers = await this.searchService.findHotSignatures(); + + return new ResponseDto( + ResponseCode.GET_SEARCH_MAIN_SUCCESS, + true, + '탐색탭 메인 화면 가져오기 성공', + getHotSignaturesDto, + ); + } catch (error) { + console.log('탐색탭 메인 가져오기 실패: ', error); + return new ResponseDto( + ResponseCode.GET_SEARCH_MAIN_FAIL, + false, + '탐색탭 메인 화면 가져오기 실패', + null, + ); + } + } + + @Get('/new') // 팀색탭 메인: 인기 급상승, 메이트의 최신 시그니처 + @UseGuards(OptionalUserGuard) + async getSearchNewSignatures( + @Req() req?: Request, + ): Promise> { + try { + const getMatesNewSignatureDto: GetSearchMainDto = new GetSearchMainDto(); + + // 로그인 했을 경우 내가 팔로우하는 메이트들의 최신 시그니처 가져오기 + if (req.user != null) + getMatesNewSignatureDto.covers = + await this.searchService.findMatesNewSignatures(req.user.id); + // 로그인 안했으면 빈 배열 + else getMatesNewSignatureDto.covers = null; + + return new ResponseDto( + ResponseCode.GET_SEARCH_MAIN_SUCCESS, + true, + '탐색탭 메인 화면 가져오기 성공', + getMatesNewSignatureDto, + ); + } catch (error) { + console.log('탐색탭 메인 가져오기 실패: ', error); + return new ResponseDto( + ResponseCode.GET_SEARCH_MAIN_FAIL, + false, + '탐색탭 메인 화면 가져오기 실패', + null, + ); + } + } + + @Get('/find') // 탑색탭 검색: 키워드로 시그니처 검색하기 + async search( + @Query('keyword') keyword: string, + ): Promise> { + try { + const searchResult: SignatureCoverDto[] = + await this.searchService.searchByKeyword(keyword); + + return new ResponseDto( + ResponseCode.SEARCH_BY_KEYWORD_SUCCESS, + true, + '키워드로 검색하기 성공', + searchResult, + ); + } catch (error) { + console.log('탑색- 키워드로 검색 실패: ' + error); + + return new ResponseDto( + ResponseCode.SEARCH_BY_KEYWORD_FAIL, + false, + '키워드로 검색하기 실패', + null, + ); + } + } +} diff --git a/src/search/search.module.ts b/src/search/search.module.ts new file mode 100644 index 0000000..99deb12 --- /dev/null +++ b/src/search/search.module.ts @@ -0,0 +1,14 @@ +// search.module.ts + +import { Module } from '@nestjs/common'; +import { SearchService } from './search.service'; +import { SearchController } from './search.controller'; +import { UserService } from '../user/user.service'; +import { SignatureService } from '../signature/signature.service'; +import { S3UtilService } from '../utils/S3.service'; + +@Module({ + controllers: [SearchController], + providers: [SearchService, UserService, SignatureService, S3UtilService], +}) +export class SearchModule {} diff --git a/src/search/search.service.ts b/src/search/search.service.ts new file mode 100644 index 0000000..2de42db --- /dev/null +++ b/src/search/search.service.ts @@ -0,0 +1,154 @@ +// search.service.ts + +import { Injectable } from '@nestjs/common'; +import { SignatureEntity } from '../signature/domain/signature.entity'; +import { SignatureCoverDto } from './dto/signature-cover.dto'; +import { SignaturePageEntity } from '../signature/domain/signature.page.entity'; +import { UserService } from '../user/user.service'; +import { Like } from 'typeorm'; +import { S3UtilService } from '../utils/S3.service'; + +@Injectable() +export class SearchService { + constructor( + private readonly userService: UserService, + private readonly s3Service: S3UtilService, + ) {} + + async findHotSignatures(): Promise { + try { + /***************************************** + 인기 시그니처 알고리즘 로직: + [1] 최근 일주일 안에 올라온 시그니처 모두 가져오기 + [2] 그 중에서 좋아요 개수 상위 20개 리턴 + *****************************************/ + + // [1] 최근 일주일 안에 올라온 시그니처 가져오기 + const recentSignatures: SignatureEntity[] = + await SignatureEntity.findRecentSignatures(); + + // [2] 최근 시그니처들 리스트 좋아요 순으로 정렬 + recentSignatures.sort((a, b) => b.liked - a.liked); + console.log(recentSignatures); + + // [3] 그 중에서 20개만 리턴한다 + return await this.getSignatureCoversForSearchMain(recentSignatures); + } catch (error) { + console.log('Error on findHotSignatures: ', error); + throw error; + } + } + + async findMatesNewSignatures(userId: number) { + try { + /******************************************************** + 내 메이트 최신 시그니처 로직: + [1] 내가 팔로우하고 있는 메이트 목록 가져오기 + [2] 각 메이트가 작성한 시그니처 중 20일 안으로 작성된 것 가져오기 + [3] 최신순으로 정렬해서 20개만 리턴 + ********************************************************/ + + // [1] 내가 팔로우하고 있는 메이트 목록 가져오기 + const followingMates = await this.userService.findFollowingMates(userId); + + // [2] 각 메이트들이 작성한 시그니처 목록에 담기 + const totalNewSignatures: SignatureEntity[] = []; + for (const mate of followingMates) { + const mateNewSignatures: SignatureEntity[] = + await SignatureEntity.findNewSignaturesByUser(mate.id); + + for (const newSignature of mateNewSignatures) { + totalNewSignatures.push(newSignature); + } + } + + // [3] 최신 순으로 정렬 + totalNewSignatures.sort( + (a, b) => b.created.getTime() - a.created.getTime(), + ); + + // [4] 20개만 리턴 + return await this.getSignatureCoversForSearchMain(totalNewSignatures); + } catch (error) { + console.log('Error on FindMatesNewSigs: ' + error); + throw error; + } + } + + async getSignatureCoversForSearchMain(signatureEntities) { + // 탐색 메인화면에 출력될 시그니처 커버 20개 만들기 + const signatureCovers: SignatureCoverDto[] = []; + + for (let i = 0; i < signatureEntities.length && i < 20; i++) { + const signature = signatureEntities[i]; + const signatureCover = await this.getSignatureCover(signature); + if (signatureCover) signatureCovers.push(signatureCover); + } + + return signatureCovers; + } + + async searchByKeyword(keyword: string) { + // 키워드로 검색하기: 탈퇴한 메이트의 시그니처도 반환 + try { + const resultSignatures = await SignatureEntity.find({ + where: { title: Like(`%${keyword}%`) }, + relations: ['user'], // user 포함 + }); + + const resultCovers = []; + + // 검색 결과 최신 순으로 정렬 + resultSignatures.sort( + (a, b) => b.created.getTime() - a.created.getTime(), + ); + + for (const signature of resultSignatures) { + const signatureCover = await this.getSignatureCover(signature); + if (signatureCover) resultCovers.push(signatureCover); + } + return resultCovers; + } catch (error) { + console.log('검색 서비스 에러발생: ' + error); + throw error; + } + } + + async getSignatureCover( + signature: SignatureEntity, // 시그니처 커버 만들기 + ): Promise { + const signatureCover = new SignatureCoverDto(); + + signatureCover._id = signature.id; + signatureCover.title = signature.title; + signatureCover.liked = signature.liked; + signatureCover.userName = signature.user.nickname; + + // 시그니처 썸네일 이미지 가져오기 + signatureCover.date = await SignatureEntity.formatDateString( + signature.created, + ); + + const signatureImageKey = await SignaturePageEntity.findThumbnail( + signature.id, + ); + if (signatureImageKey != null) { + signatureCover.image = await this.s3Service.getImageUrl( + signatureImageKey, + ); + } else return null; + + // 시그니처 작성자 프로필 이미지 가져오기 + const userProfileImageEntity = await this.userService.getProfileImage( + signature.user.id, + ); + if (userProfileImageEntity == null) signatureCover.userImage = null; + else { + const userProfileImageKey = userProfileImageEntity.imageKey; + signatureCover.userImage = await this.s3Service.getImageUrl( + userProfileImageKey, + ); + } + return signatureCover; + } +} diff --git a/src/signature/domain/signature.comment.entity.ts b/src/signature/domain/signature.comment.entity.ts new file mode 100644 index 0000000..438ef84 --- /dev/null +++ b/src/signature/domain/signature.comment.entity.ts @@ -0,0 +1,56 @@ +// signature.comment.entity.ts + +import { + BaseEntity, + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + JoinColumn, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { SignatureEntity } from './signature.entity'; +import { UserEntity } from 'src/user/user.entity'; + +@Entity() +export class SignatureCommentEntity extends BaseEntity { + @PrimaryGeneratedColumn() + id: number; + + @ManyToOne(() => SignatureEntity, (signature) => signature.comments) + @JoinColumn() + signature: SignatureEntity; + + @ManyToOne(() => UserEntity, (user) => user.signatureComments) + @JoinColumn() + user: UserEntity; + + @OneToMany( + () => SignatureCommentEntity, + (childComment) => childComment.parentComment, + ) + @JoinColumn() + childComments: SignatureCommentEntity[]; + + @ManyToOne( + () => SignatureCommentEntity, + (parentComment) => parentComment.childComments, + ) + @JoinColumn() + parentComment: SignatureCommentEntity; + + @Column() + content: string; + + @CreateDateColumn() + created: Date; + + @UpdateDateColumn() + updated: Date; + + @DeleteDateColumn() + deleted: Date; +} diff --git a/src/signature/domain/signature.entity.ts b/src/signature/domain/signature.entity.ts new file mode 100644 index 0000000..7953244 --- /dev/null +++ b/src/signature/domain/signature.entity.ts @@ -0,0 +1,167 @@ +// signature.entity.ts + +import { + BaseEntity, + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + EntitySubscriberInterface, + EventSubscriber, + InsertEvent, + JoinColumn, + ManyToOne, + MoreThan, + OneToMany, + PrimaryGeneratedColumn, + RemoveEvent, + UpdateDateColumn, +} from 'typeorm'; +import { UserEntity } from 'src/user/user.entity'; +import { CreateSignatureDto } from '../dto/signature/create-signature.dto'; +import { SignaturePageEntity } from './signature.page.entity'; +import { SignatureLikeEntity } from './signature.like.entity'; +import { SignatureCommentEntity } from './signature.comment.entity'; +@Entity() +@EventSubscriber() +export class SignatureEntity + extends BaseEntity + implements EntitySubscriberInterface +{ + @PrimaryGeneratedColumn() + id: number; + + @Column() + title: string; + + @Column({ default: 0 }) + liked: number; + + @ManyToOne(() => UserEntity, (user) => user.signatures) + @JoinColumn({ name: 'user_id' }) + user: UserEntity; + + @OneToMany( + () => SignaturePageEntity, + (signaturePage) => signaturePage.signature, + ) + signaturePages: SignaturePageEntity[]; + + @OneToMany( + () => SignatureLikeEntity, + (signatureLike) => signatureLike.signature, + ) + likes: SignatureLikeEntity[]; + + @OneToMany( + () => SignatureCommentEntity, + (signatureComment) => signatureComment.signature, + ) + comments: SignatureCommentEntity[]; + + listenTo() { + return SignatureLikeEntity; + } + + // SignatureLikeEntity 삽입 이벤트에 대한 이벤트 리스너 + beforeInsert(event: InsertEvent): void { + this.updateLikedCount(event.entity, 1); + } + + // SignatureLikeEntity 삭제 이벤트에 대한 이벤트 리스너 + beforeRemove(event: RemoveEvent): void { + this.updateLikedCount(event.entity, -1); + } + + // 변경된 값에 따라 liked 카운트 업데이트 + private updateLikedCount(entity: SignatureLikeEntity, change: number): void { + this.liked += change; + this.save(); // 업데이트된 liked 카운트를 데이터베이스에 저장 + } + + @CreateDateColumn() + created: Date; + + @UpdateDateColumn() + updated: Date; + + @DeleteDateColumn() + deleted: Date; + + static async formatDateString(date: Date): Promise { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + + return `${year}.${month}.${day}`; + } + + static async createSignature( + createSignatureDto: CreateSignatureDto, + userId: number, + ): Promise { + try { + const signature: SignatureEntity = new SignatureEntity(); + signature.title = createSignatureDto.title; + + const user: UserEntity = await UserEntity.findOne({ + where: { id: userId }, + }); + + if (!user) { + throw new Error('User not found'); + } else { + console.log('user name: ' + user.name); + signature.user = user; + + return await signature.save(); + } + } catch (error) { + console.error('Error creating Signature:', error); + throw new Error('Failed to create Signature'); + } + } + + static async findSignatureById( + signatureId: number, + ): Promise { + const signature: SignatureEntity = await SignatureEntity.findOne({ + where: { id: signatureId }, + relations: ['user'], // user 포함 + }); + + return signature; + } + + static async findRecentSignatures(): Promise { + // [1] 기준이 되는 일주일 전 날짜 + const sevenDaysAgo: Date = new Date(); + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); + console.log(sevenDaysAgo); + + // [2] 오늘로부터 일주일 안으로 쓰여진 시그니처 가져오기 + const recentSignatures = await SignatureEntity.find({ + where: { + created: MoreThan(sevenDaysAgo), + user: { isQuit: false }, // 탈퇴한 사용자의 시그니처는 추천에서 제외 + }, + relations: ['user'], // user 포함 + }); + + return recentSignatures; + } + + static async findNewSignaturesByUser(userId: number) { + // [1] 기준이 되는 20일 전 날짜 + const twentyDaysAgo: Date = new Date(); + twentyDaysAgo.setDate(twentyDaysAgo.getDate() - 20); + console.log(twentyDaysAgo); + + // [2] 20일 전에 쓰인 메이트의 최신 시그니처 가져오기 + const signatures = await SignatureEntity.find({ + where: { user: { id: userId }, created: MoreThan(twentyDaysAgo) }, + relations: ['user'], // user 포함 + }); + return signatures; + } +} diff --git a/src/signature/domain/signature.like.entity.ts b/src/signature/domain/signature.like.entity.ts new file mode 100644 index 0000000..42a8e0d --- /dev/null +++ b/src/signature/domain/signature.like.entity.ts @@ -0,0 +1,60 @@ +// signature.like.entity.ts + +import { + BaseEntity, + CreateDateColumn, + DeleteDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { SignatureEntity } from './signature.entity'; +import { UserEntity } from 'src/user/user.entity'; + +@Entity() +export class SignatureLikeEntity extends BaseEntity { + @PrimaryGeneratedColumn() + id: number; + + @ManyToOne(() => SignatureEntity, (signature) => signature.likes) + @JoinColumn({ name: 'signature_id' }) + signature: SignatureEntity; + + @ManyToOne(() => UserEntity, (user) => user.likes) + @JoinColumn({ name: 'user_id' }) + user: UserEntity; + + @CreateDateColumn() + created: Date; + + @UpdateDateColumn() + updated: Date; + + @DeleteDateColumn() + deleted: Date; + + static async createLike(signature: SignatureEntity, loginUser: UserEntity) { + try { + const signatureLike = new SignatureLikeEntity(); + signatureLike.signature = signature; + signatureLike.user = loginUser; + const signatureLikeEntity = await signatureLike.save(); + console.log('sigLike created: ', signatureLikeEntity); + } catch (error) { + console.error('Error on likeSignature: ', error); + throw new Error('Failed to like Signature'); + } + } + + static async findSignatureLikes(signatureId: number) { + return await SignatureLikeEntity.find({ + where: { + signature: { id: signatureId }, + user: { isQuit: false }, // 탈퇴한 유저의 좋아요는 가져오지 않음 + }, + relations: ['user', 'signature'], + }); + } +} diff --git a/src/signature/domain/signature.page.entity.ts b/src/signature/domain/signature.page.entity.ts new file mode 100644 index 0000000..fc6321a --- /dev/null +++ b/src/signature/domain/signature.page.entity.ts @@ -0,0 +1,78 @@ +// signature.entity.ts + +import { + BaseEntity, + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { SignatureEntity } from './signature.entity'; + +@Entity() +export class SignaturePageEntity extends BaseEntity { + @PrimaryGeneratedColumn() + id: number; + + @Column() + page: number; + + @Column({ type: 'mediumtext' }) + content: string; + + @Column() + location: string; + + @Column() + image: string; + + @ManyToOne(() => SignatureEntity, (signature) => signature.signaturePages) + @JoinColumn({ name: 'signature_id' }) + signature: SignatureEntity; + + @CreateDateColumn() + created: Date; + + @UpdateDateColumn() + updated: Date; + + @DeleteDateColumn() + deleted: Date; + + static async findThumbnail(signatureId: number) { + // 각 시그니처의 첫 번째 페이지의 이미지 가져오기 + try { + const firstPage = await SignaturePageEntity.findOne({ + where: { + signature: { id: signatureId }, + page: 1, + }, + }); + + console.log('첫번째 페이지: ', firstPage); + + if (firstPage == null) return null; + else { + console.log('썸네일 이미지: ', firstPage.image); + return firstPage.image; + } + } catch (error) { + console.log('Error on findThumbnail: ', error); + throw error; + } + } + + static async findSignaturePages(signatureId: number) { + const pages: SignaturePageEntity[] = await SignaturePageEntity.find({ + where: { + signature: { id: signatureId }, + }, + }); + + return pages; + } +} diff --git a/src/signature/dto/comment/create-comment.dto.ts b/src/signature/dto/comment/create-comment.dto.ts new file mode 100644 index 0000000..986074e --- /dev/null +++ b/src/signature/dto/comment/create-comment.dto.ts @@ -0,0 +1,5 @@ +// create-comment.dto.ts + +export class CreateCommentDto { + content: string; // 댓글 내용 +} diff --git a/src/signature/dto/comment/get-comment-writer.dto.ts b/src/signature/dto/comment/get-comment-writer.dto.ts new file mode 100644 index 0000000..8607d71 --- /dev/null +++ b/src/signature/dto/comment/get-comment-writer.dto.ts @@ -0,0 +1,8 @@ +// get-comment-writer.dto.ts + +export class GetCommentWriterDto { + _id: number; + name: string; + image: string; // 프로필 이미지 + is_writer: boolean; // 로그인 유저의 수정 삭제 가능 여부 +} diff --git a/src/signature/dto/comment/get-signature-comment.dto.ts b/src/signature/dto/comment/get-signature-comment.dto.ts new file mode 100644 index 0000000..0949d80 --- /dev/null +++ b/src/signature/dto/comment/get-signature-comment.dto.ts @@ -0,0 +1,13 @@ +// get-signature-comment.dto.ts + +import { GetCommentWriterDto } from './get-comment-writer.dto'; + +export class GetSignatureCommentDto { + _id: number; + parentId: number; + content: string; + writer: GetCommentWriterDto; + date: Date; // 생성 | 수정일 + is_edited: boolean; // 댓글 수정 여부 + can_delete: boolean; // 로그인한 사용자의 댓글 삭제 권한 여부: 시그니처 작성자면 true +} diff --git a/src/signature/dto/like/get-like-list.dto.ts b/src/signature/dto/like/get-like-list.dto.ts new file mode 100644 index 0000000..70de855 --- /dev/null +++ b/src/signature/dto/like/get-like-list.dto.ts @@ -0,0 +1,8 @@ +// get-like-list.dto.ts + +import { LikeProfileDto } from './like-profile.dto'; + +export class GetLikeListDto { + liked: number; // 좋아요 개수 + profiles: LikeProfileDto[]; // 좋아요한 사용자 프로필 리스트 +} diff --git a/src/signature/dto/like/like-profile.dto.ts b/src/signature/dto/like/like-profile.dto.ts new file mode 100644 index 0000000..409bd99 --- /dev/null +++ b/src/signature/dto/like/like-profile.dto.ts @@ -0,0 +1,9 @@ +// like-profile.dto.ts + +export class LikeProfileDto { + _id: number; + nickname: string; + introduction: string; + is_followed: boolean; + image: string; +} diff --git a/src/signature/dto/like/like-signature.dto.ts b/src/signature/dto/like/like-signature.dto.ts new file mode 100644 index 0000000..e47d589 --- /dev/null +++ b/src/signature/dto/like/like-signature.dto.ts @@ -0,0 +1,6 @@ +// like-signature.dto.ts + +export class LikeSignatureDto { + signatureId: number; + liked: number; +} diff --git a/src/signature/dto/signature/author-signature.dto.ts b/src/signature/dto/signature/author-signature.dto.ts new file mode 100644 index 0000000..5c8f728 --- /dev/null +++ b/src/signature/dto/signature/author-signature.dto.ts @@ -0,0 +1,9 @@ +// author-signature.dto.ts + +export class AuthorSignatureDto { + // 시그니처 작성자 정보 + _id: number; // 메이트 아이디 + name: string; // 메이트 닉네임 + image: string; // 메이트 프로필 이미지 + is_followed: boolean; // 해당 메이트 팔로우 여부 +} diff --git a/src/signature/dto/signature/create-signature.dto.ts b/src/signature/dto/signature/create-signature.dto.ts new file mode 100644 index 0000000..21b9676 --- /dev/null +++ b/src/signature/dto/signature/create-signature.dto.ts @@ -0,0 +1,8 @@ +// create-signature.dto.ts + +import { PageSignatureDto } from './page-signature.dto'; + +export class CreateSignatureDto { + title: string; + pages: PageSignatureDto[]; +} diff --git a/src/signature/dto/signature/detail-signature.dto.ts b/src/signature/dto/signature/detail-signature.dto.ts new file mode 100644 index 0000000..6615f09 --- /dev/null +++ b/src/signature/dto/signature/detail-signature.dto.ts @@ -0,0 +1,12 @@ +// detail-signature.dto.ts + +import { AuthorSignatureDto } from './author-signature.dto'; +import { HeaderSignatureDto } from './header-signature.dto'; +import { ResponsePageSignatureDto } from './response-page-signature.dto'; + +export class DetailSignatureDto { + // 시그니처 상세 보기 + author: AuthorSignatureDto; // 시그니처 작성자 정보 + header: HeaderSignatureDto; // 시그니처 제목 및 좋아요 정보 + pages: ResponsePageSignatureDto[]; // 시그니처 각 페이지 내용 +} diff --git a/src/signature/dto/signature/header-signature.dto.ts b/src/signature/dto/signature/header-signature.dto.ts new file mode 100644 index 0000000..1a4db1d --- /dev/null +++ b/src/signature/dto/signature/header-signature.dto.ts @@ -0,0 +1,9 @@ +// header-signature.dto.ts + +export class HeaderSignatureDto { + _id: number; // 시그니처 아이디 + title: string; // 시그니처 제목 + is_liked: boolean; // 해당 시그니처 좋아요 여부 + like_cnt: number; // 좋아요 개수 + date: string; // 발행일 +} diff --git a/src/signature/dto/signature/home-signature.dto.ts b/src/signature/dto/signature/home-signature.dto.ts new file mode 100644 index 0000000..1cbe72e --- /dev/null +++ b/src/signature/dto/signature/home-signature.dto.ts @@ -0,0 +1,8 @@ +// home-signature.dto.ts + +export class HomeSignatureDto { + _id: number; // 시그니처 id + title: string; // 시그니처 제목 + date: Date; // 시그니처 발행일 + image: string; // 시그니처 첫번째 페이지의 이미지(썸네일) +} diff --git a/src/signature/dto/signature/page-signature.dto.ts b/src/signature/dto/signature/page-signature.dto.ts new file mode 100644 index 0000000..f942014 --- /dev/null +++ b/src/signature/dto/signature/page-signature.dto.ts @@ -0,0 +1,10 @@ +// page-signature.dto.ts + +export class PageSignatureDto { + _id: number; + page: number; + content: string; + location: string; + //image: Buffer; // form-data 형식 + image: string; // base-64 형식 +} diff --git a/src/signature/dto/signature/response-page-signature.dto.ts b/src/signature/dto/signature/response-page-signature.dto.ts new file mode 100644 index 0000000..a9209dc --- /dev/null +++ b/src/signature/dto/signature/response-page-signature.dto.ts @@ -0,0 +1,9 @@ +// response-page-signature.dto.ts + +export class ResponsePageSignatureDto { + _id: number; + page: number; + content: string; + location: string; + image: string; +} diff --git a/src/signature/signature.comment.controller.ts b/src/signature/signature.comment.controller.ts new file mode 100644 index 0000000..4cb368b --- /dev/null +++ b/src/signature/signature.comment.controller.ts @@ -0,0 +1,208 @@ +// signature.comment.controller.ts + +import { + Body, + Controller, + Delete, + ForbiddenException, + Get, + NotFoundException, + Param, + Patch, + Post, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import { SignatureCommentService } from './signature.comment.service'; +import { UserGuard } from '../user/user.guard'; +import { Request } from 'express'; +import { CreateCommentDto } from './dto/comment/create-comment.dto'; +import { ResponseDto } from '../response/response.dto'; +import { ResponseCode } from '../response/response-code.enum'; +import { CursorPageOptionsDto } from '../rule/dto/cursor-page.options.dto'; + +@Controller('signature/:signatureId/comment') +export class SignatureCommentController { + constructor( + private readonly signatureCommentService: SignatureCommentService, + ) {} + + @Post('/') + @UseGuards(UserGuard) + async createSignatureComment( + // 시그니처 댓글 생성하기 + @Req() req: Request, + @Param('signatureId') signatureId: number, + @Body() newComment: CreateCommentDto, + ) { + try { + const result = await this.signatureCommentService.createSignatureComment( + newComment, + req.user.id, + signatureId, + ); + + return new ResponseDto( + ResponseCode.CREATE_SIGNATURE_COMMENT_SUCCESS, + true, + '시그니처 댓글 생성 성공', + result, + ); + } catch (error) { + console.log('Error on createSigComment: ', error); + return new ResponseDto( + ResponseCode.COMMENT_CREATION_FAIL, + false, + '시그니처 댓글 생성 실패', + null, + ); + } + } + + @Post('/:parentId') + @UseGuards(UserGuard) + async createSignatureReplyComment( + // 시그니처 답글 생성하기 + @Req() req: Request, + @Param('signatureId') signatureId: number, + @Param('parentId') parentId: number, + @Body() newComment: CreateCommentDto, + ) { + try { + const result = await this.signatureCommentService.createSignatureComment( + newComment, + req.user.id, + signatureId, + parentId, + ); + + return new ResponseDto( + ResponseCode.CREATE_SIGNATURE_COMMENT_SUCCESS, + true, + '시그니처 답글 생성 성공', + result, + ); + } catch (error) { + console.log('Error on createSigComment: ', error); + return new ResponseDto( + ResponseCode.COMMENT_CREATION_FAIL, + false, + '시그니처 답글 생성 실패', + null, + ); + } + } + + @Get('/') + @UseGuards(UserGuard) + async getSignatureComment( + // 시그니처 댓글 조회하기 (무한 스크롤) + @Req() req: Request, + @Param('signatureId') signatureId: number, + @Query() cursorPageOptionsDto: CursorPageOptionsDto, + ) { + try { + const result = await this.signatureCommentService.getSignatureComment( + cursorPageOptionsDto, + req.user.id, + signatureId, + ); + + return new ResponseDto( + ResponseCode.GET_COMMENT_DETAIL_SUCCESS, + true, + '시그니처 댓글 가져오기 성공', + result, + ); + } catch (error) { + console.log('Error on createSigChildComment: ', error); + return new ResponseDto( + ResponseCode.GET_COMMENT_DETAIL_FAIL, + false, + '시그니처 댓글 가져오기 실패', + null, + ); + } + } + + @Patch('/:commentId') + @UseGuards(UserGuard) + async patchSignatureComment( + // 시그니처 수정하기 + @Param('signatureId') signatureId: number, + @Param('commentId') commentId: number, + @Body() patchedComment: CreateCommentDto, + @Req() req: Request, + ) { + try { + const result = await this.signatureCommentService.patchSignatureComment( + req.user.id, + signatureId, + commentId, + patchedComment, + ); + + return new ResponseDto( + ResponseCode.COMMENT_UPDATE_SUCCESS, + true, + '시그니처 댓글 수정하기 성공', + result, + ); + } catch (error) { + console.log('Err on PatchSigComment: ' + error); + let errorMessage = ''; + + if (error instanceof NotFoundException) errorMessage = error.message; + else if (error instanceof ForbiddenException) + errorMessage = error.message; + else errorMessage = '시그니처 댓글 수정하기 실패'; + + return new ResponseDto( + ResponseCode.COMMENT_UPDATE_FAIL, + false, + errorMessage, + null, + ); + } + } + + @Delete('/:commentId') + @UseGuards(UserGuard) + async deleteSignatureComment( + // 시그니처 수정하기 + @Param('signatureId') signatureId: number, + @Param('commentId') commentId: number, + @Req() req: Request, + ) { + try { + const result = await this.signatureCommentService.deleteSignatureComment( + req.user.id, + signatureId, + commentId, + ); + + return new ResponseDto( + ResponseCode.COMMENT_DELETE_SUCCESS, + true, + '시그니처 댓글 삭제하기 성공', + result, + ); + } catch (error) { + console.log('Err on DeleteSigComment: ' + error); + let errorMessage = ''; + + if (error instanceof NotFoundException) errorMessage = error.message; + else if (error instanceof ForbiddenException) + errorMessage = error.message; + else errorMessage = '시그니처 댓글 삭제하기 실패'; + + return new ResponseDto( + ResponseCode.COMMENT_DELETE_FAIL, + false, + errorMessage, + null, + ); + } + } +} diff --git a/src/signature/signature.comment.service.ts b/src/signature/signature.comment.service.ts new file mode 100644 index 0000000..67515de --- /dev/null +++ b/src/signature/signature.comment.service.ts @@ -0,0 +1,339 @@ +// signature.comment.service.ts + +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { UserService } from '../user/user.service'; +import { S3UtilService } from '../utils/S3.service'; +import { SignatureService } from './signature.service'; +import { CreateCommentDto } from './dto/comment/create-comment.dto'; +import { SignatureCommentEntity } from './domain/signature.comment.entity'; +import { UserEntity } from '../user/user.entity'; +import { SignatureEntity } from './domain/signature.entity'; +import { CursorPageOptionsDto } from '../rule/dto/cursor-page.options.dto'; +import { MoreThan, Not, Raw } from 'typeorm'; +import { GetSignatureCommentDto } from './dto/comment/get-signature-comment.dto'; +import { GetCommentWriterDto } from './dto/comment/get-comment-writer.dto'; +import { CursorPageMetaDto } from '../mate/cursor-page/cursor-page.meta.dto'; +import { CursorPageDto } from '../mate/cursor-page/cursor-page.dto'; +import { NotificationEntity } from '../notification/notification.entity'; + +@Injectable() +export class SignatureCommentService { + constructor( + private readonly signatureService: SignatureService, + private readonly userService: UserService, + private readonly s3Service: S3UtilService, + ) {} + + async createSignatureComment( + // 댓글, 답글 생성하기 + createCommentDto: CreateCommentDto, + userId: number, + signatureId: number, + parentCommentId?: number, + ) { + const comment = new SignatureCommentEntity(); + + const user = await UserEntity.findOneOrFail({ where: { id: userId } }); + const signature = await SignatureEntity.findOneOrFail({ + where: { id: signatureId }, + relations: { user: true }, + }); + + if (!user || !signature) { + throw new NotFoundException('404 Not Found'); + } else { + comment.user = user; + comment.signature = signature; + comment.content = createCommentDto.content; + + // 알림 생성 + const notification = new NotificationEntity(); + + // parentCommentId가 존재할 경우 -> 답글 / 존재하지 않을 경우 -> 댓글 + if (parentCommentId) { + // 대댓글: parentId는 파라미터로 받은 parentCommentId로 설정 + + const parentComment = await SignatureCommentEntity.findOneOrFail({ + where: { id: parentCommentId }, + relations: { user: true }, + }); + + if (!parentComment) throw new NotFoundException('404 Not Found'); + else { + comment.parentComment = parentComment; + await comment.save(); + } + + notification.notificationReceiver = parentComment.user; + notification.notificationTargetDesc = parentComment.content; + } else { + // 댓글: parentId는 본인으로 설정 + const savedComment = await comment.save(); + savedComment.parentComment = savedComment; + await savedComment.save(); + + notification.notificationReceiver = signature.user; + notification.notificationTargetDesc = signature.title; + } + + notification.notificationSender = user; + notification.notificationTargetType = 'SIGNATURE'; + notification.notificationTargetId = signature.id; + notification.notificationAction = 'COMMENT'; + await notification.save(); + + return comment.id; + } + } + + async getSignatureComment( + // 댓글 가져오기 + cursorPageOptionsDto: CursorPageOptionsDto, + userId: number, + signatureId: number, + ) { + try { + // 1. 'cursorId'부터 오름차순 정렬된 댓글 'take'만큼 가져오기 + const [comments, total] = await SignatureCommentEntity.findAndCount({ + take: cursorPageOptionsDto.take, + where: { + id: MoreThan(cursorPageOptionsDto.cursorId), + signature: { id: signatureId }, + parentComment: { id: Raw('SignatureCommentEntity.id') }, // 부모 댓글이 자기 자신인 댓글들만 가져오기 + }, + relations: { + user: { profileImage: true }, + parentComment: true, + signature: { user: true }, + }, + order: { + parentComment: { id: 'ASC' as any }, + created: 'ASC', + }, + }); + + const result: GetSignatureCommentDto[] = []; + + // 2. 각 부모 댓글의 답글들 찾아오기 + for (const comment of comments) { + console.log(comment); + result.push(await this.createSignatureCommentDto(comment, userId)); + + const childrenComments = await SignatureCommentEntity.find({ + // 답글 찾아오기 + where: { + parentComment: { id: comment.id }, + id: Not(Raw('SignatureCommentEntity.parentComment.id')), + }, + relations: { + user: { profileImage: true }, + parentComment: true, + signature: { user: true }, + }, + order: { + created: 'ASC', + }, + }); + + for (const childComment of childrenComments) { + console.log(childComment); + result.push( + await this.createSignatureCommentDto(childComment, userId), + ); + } + } + + // 3. 스크롤 설정 + let hasNextData = true; + let cursor: number; + + const takePerScroll = cursorPageOptionsDto.take; + const isLastScroll = total <= takePerScroll; + const lastDataPerScroll = comments[comments.length - 1]; + + if (isLastScroll) { + hasNextData = false; + cursor = null; + } else { + cursor = lastDataPerScroll.id; + } + + const cursorPageMetaDto = new CursorPageMetaDto({ + cursorPageOptionsDto, + total, + hasNextData, + cursor, + }); + + return new CursorPageDto(result, cursorPageMetaDto); + } catch (e) { + console.log('Error on GetSignature: ', e); + throw e; + } + } + + async createSignatureCommentDto( + comment: SignatureCommentEntity, + userId: number, + ) { + // 댓글 DTO 만들기 + const writerProfile = new GetCommentWriterDto(); + const getCommentDto = new GetSignatureCommentDto(); + + // 2-[1] 댓글 작성자 정보 담기 + writerProfile._id = comment.user.id; + writerProfile.name = comment.user.nickname; + + // 로그인한 사용자가 댓글 작성자인지 확인 + if (userId == comment.user.id) writerProfile.is_writer = true; + else writerProfile.is_writer = false; + + // 작성자 프로필 이미지 + const image = comment.user.profileImage; + if (image == null) writerProfile.image = null; + else { + const userImageKey = image.imageKey; + writerProfile.image = await this.s3Service.getImageUrl(userImageKey); + } + + // 2-[2] 댓글 정보 담기 + getCommentDto._id = comment.id; + getCommentDto.content = comment.content; + getCommentDto.parentId = comment.parentComment.id; + getCommentDto.writer = writerProfile; + getCommentDto.date = comment.updated; + + // 댓글 수정 여부 구하기 + const createdTime = comment.created.getTime(); + const updatedTime = comment.updated.getTime(); + + if (Math.abs(createdTime - updatedTime) <= 2000) { + // 두 시간 차가 2초 이하면 수정 안함 + getCommentDto.is_edited = false; + } else { + getCommentDto.is_edited = true; + } + + // 로그인한 사용자가 시그니처 작성하면 can_delete = true + let can_delete = false; + if (comment.signature.user) { + // 시그니처 작성자가 존재할 경우 + if (comment.signature.user.id == userId) { + // 로그인한 사용자가 시그니처 작성자일 경우 댓글 삭제 가능 + can_delete = true; + } + } + getCommentDto.can_delete = can_delete; + + return getCommentDto; + } + + async patchSignatureComment( + // 댓글 수정하기 + userId: number, + signatureId: number, + commentId: number, + patchedComment: CreateCommentDto, + ) { + // 시그니처 유효한지 확인 + const signature = await SignatureEntity.findOne({ + where: { id: signatureId }, + relations: { user: true }, + }); + if (!signature) throw new NotFoundException('존재하지 않는 시그니처입니다'); + + // 댓글 데이터 유효한지 확인 + const comment = await SignatureCommentEntity.findOne({ + where: { id: commentId }, + relations: { user: true }, + }); + if (!comment) throw new NotFoundException('존재하지 않는 댓글입니다'); + + let forbiddenUser = true; + // 댓글 작성자가 로그인한 사용자 본인 혹은 시그니처 작성자가 맞는지 확인 + if (signature.user) { + // 시그니처 작성자가 존재한다면 시그니처 작성자와 로그인한 사용자가 일치하는지 확인 + if (signature.user.id == userId) forbiddenUser = false; + } + + if (comment.user.id) { + // 댓글 작성자가 존재한다면 댓글 작성자와 로그인한 사용자가 일치하는지 확인 + if (comment.user.id == userId) forbiddenUser = false; + } + + if (forbiddenUser) + throw new ForbiddenException('댓글 수정 권한이 없습니다'); + + // 댓글 수정하기 + comment.content = patchedComment.content; + await comment.save(); + return comment.id; + } + + async deleteSignatureComment( + userId: number, + signatureId: number, + commentId: number, + ) { + try { + // 시그니처 유효한지 확인 + const signature = await SignatureEntity.findOne({ + where: { id: signatureId }, + relations: { user: true }, + }); + if (!signature) + throw new NotFoundException('존재하지 않는 시그니처입니다'); + + // 댓글 데이터 유효한지 확인 + const comment = await SignatureCommentEntity.findOne({ + where: { id: commentId }, + relations: ['user', 'parentComment', 'signature'], + }); + if (!comment) throw new NotFoundException('존재하지 않는 댓글입니다'); + + let forbiddenUser = true; + // 댓글 작성자가 로그인한 사용자 본인 혹은 시그니처 작성자가 맞는지 확인 + if (signature.user) { + // 시그니처 작성자가 존재한다면 시그니처 작성자와 로그인한 사용자가 일치하는지 확인 + if (signature.user.id == userId) forbiddenUser = false; + } + + if (comment.user.id) { + // 댓글 작성자가 존재한다면 댓글 작성자와 로그인한 사용자가 일치하는지 확인 + if (comment.user.id == userId) forbiddenUser = false; + } + + if (forbiddenUser) + throw new ForbiddenException('댓글 삭제 권한이 없습니다'); + + // 해당 댓글이 부모 댓글인 경우 자식 댓글 모두 삭제 + if (commentId == comment.parentComment.id) { + // 자식 댓글 모두 찾아오기 + const replyComments: SignatureCommentEntity[] = + await SignatureCommentEntity.find({ + where: { parentComment: { id: commentId } }, + }); + + // 자식 댓글 모두 삭제 + for (const reply of replyComments) { + await reply.softRemove(); + } + + // 자식 모두 삭제했으면 부모 댓글 삭제 + await comment.softRemove(); + } else { + // 자식 댓글 없는 경우 본인만 삭제 + await comment.softRemove(); + } + + return commentId; + } catch (error) { + console.log(error); + throw error; + } + } +} diff --git a/src/signature/signature.controller.ts b/src/signature/signature.controller.ts new file mode 100644 index 0000000..6516a47 --- /dev/null +++ b/src/signature/signature.controller.ts @@ -0,0 +1,298 @@ +// signature.controller.ts + +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import { SignatureService } from './signature.service'; +import { CreateSignatureDto } from './dto/signature/create-signature.dto'; +import { ResponseCode } from '../response/response-code.enum'; +import { ResponseDto } from '../response/response.dto'; +import { HomeSignatureDto } from './dto/signature/home-signature.dto'; +import { DetailSignatureDto } from './dto/signature/detail-signature.dto'; +import { SignatureEntity } from './domain/signature.entity'; +import { SignatureLikeEntity } from './domain/signature.like.entity'; +import { LikeSignatureDto } from './dto/like/like-signature.dto'; +import { GetLikeListDto } from './dto/like/get-like-list.dto'; +import { UserGuard } from '../user/user.guard'; +import { Request } from 'express'; + +@Controller('signature') +export class SignatureController { + constructor(private readonly signatureService: SignatureService) {} + + @Get('/') // 시그니처 탭 메인: 내 시그니처 목록 + @UseGuards(UserGuard) + async getMySignature( + @Req() req: Request, + ): Promise> { + const result = await this.signatureService.homeSignature(req.user.id); + + if (!result) { + return new ResponseDto( + ResponseCode.GET_MY_SIGNATURE_FAIL, + false, + '내 시그니처 가져오기 실패', + null, + ); + } else { + return new ResponseDto( + ResponseCode.GET_MY_SIGNATURES_SUCCESS, + true, + '내 시그니처 가져오기 성공', + result, + ); + } + } + + @Post('/new') // 시그니처 생성하기 + @UseGuards(UserGuard) + async createNewSignature( + @Body() newSignature: CreateSignatureDto, + @Req() req: Request, + ): Promise> { + const result = await this.signatureService.createSignature( + newSignature, + req.user.id, + ); + + if (!result) { + return new ResponseDto( + ResponseCode.SIGNATURE_CREATION_FAIL, + false, + '시그니처 생성에 실패했습니다', + null, + ); + } else { + return new ResponseDto( + ResponseCode.SIGNATURE_CREATED, + true, + '시그니처 기록하기 성공', + result, + ); + } + } + + @Patch('/like/:signatureId') // 시그니처 좋아요 등록 or 취소 + @UseGuards(UserGuard) + async patchSignatureLike( + @Param('signatureId') signatureId: number, + @Req() req: Request, + ): Promise> { + try { + // [1] 이미 좋아요 했는지 확인 + const liked: SignatureLikeEntity = + await this.signatureService.findIfAlreadyLiked( + req.user.id, + signatureId, + ); + let result = new SignatureEntity(); + const likeSignatureDto = new LikeSignatureDto(); + + if (liked) { + // 이미 좋아요했던 시그니처라면 좋아요 삭제 + result = await this.signatureService.deleteLikeOnSignature( + liked, + signatureId, + ); + likeSignatureDto.liked = result.liked; + likeSignatureDto.signatureId = result.id; + + return new ResponseDto( + ResponseCode.DELETE_LIKE_ON_SIGNATURE_SUCCESS, + true, + '시그니처 좋아요 취소하기 성공', + likeSignatureDto, + ); + } else { + // 좋아요 한적 없으면 시그니처 좋아요 추가 + result = await this.signatureService.addLikeOnSignature( + req.user.id, + signatureId, + ); + likeSignatureDto.liked = result.liked; + likeSignatureDto.signatureId = result.id; + + return new ResponseDto( + ResponseCode.LIKE_ON_SIGNATURE_CREATED, + true, + '시그니처 좋아요 성공', + likeSignatureDto, + ); + } + } catch (error) { + console.log('addSignatureLike: ', error); + throw error; + } + } + + @Get('/:signatureId') // 시그니처 상세보기 + @UseGuards(UserGuard) + async getSignatureDetail( + @Req() req: Request, + @Param('signatureId') signatureId: number, + ): Promise> { + try { + // 임시로 토큰이 아닌 유저 아이디 받도록 구현 -> 리펙토링 예정 + const result = await this.signatureService.detailSignature( + req.user.id, + signatureId, + ); + + if (result == null) { + return new ResponseDto( + ResponseCode.SIGNATURE_NOT_FOUND, + false, + '존재하지 않는 시그니처 입니다', + result, + ); + } + + if (result.author.is_followed) { + // 작성자가 본인이 아닌 경우 + if (result.author._id == null) { + // 작성자가 탈퇴한 경우 + return new ResponseDto( + ResponseCode.GET_SIGNATURE_DETAIL_SUCCESS, + true, + '시그니처 상세보기 성공: 작성자 탈퇴', + result, + ); + } else { + return new ResponseDto( + ResponseCode.GET_SIGNATURE_DETAIL_SUCCESS, + true, + '시그니처 상세보기 성공: 메이트의 시그니처', + result, + ); + } + } else { + // 작성자가 본인인 경우 author 없음 + return new ResponseDto( + ResponseCode.GET_SIGNATURE_DETAIL_SUCCESS, + true, + '시그니처 상세보기 성공: 내 시그니처', + result, + ); + } + } catch (error) { + console.log('Error on signatureId: ', error); + throw error; + } + } + + @Patch('/:signatureId') // 시그니처 수정하기 + async patchSignature( + @Body() patchSignatureDto: CreateSignatureDto, + @Param('signatureId') signatureId: number, + ): Promise> { + try { + // 임시로 토큰이 아닌 유저 아이디 받도록 구현 -> 리펙토링 예정 + const result = await this.signatureService.patchSignature( + signatureId, + patchSignatureDto, + ); + + if (result == null) { + return new ResponseDto( + ResponseCode.SIGNATURE_NOT_FOUND, + false, + '존재하지 않는 시그니처 입니다', + result, + ); + } + + return new ResponseDto( + ResponseCode.PATCH_SIGNATURE_SUCCESS, + true, + '시그니처 수정하기 성공', + result, + ); + } catch (error) { + console.log(error); + return new ResponseDto( + ResponseCode.SIGNATURE_PATCH_FAIL, + false, + '시그니처 수정하기 실패', + null, + ); + } + } + + @Delete('/:signatureId') // 시그니처 삭제하기 + async deleteSignature( + @Param('signatureId') signatureId: number, + ): Promise> { + try { + // 임시로 토큰이 아닌 유저 아이디 받도록 구현 -> 리펙토링 예정 + + // [1] 시그니처 가져오기 + const signature: SignatureEntity = + await SignatureEntity.findSignatureById(signatureId); + console.log('시그니처 정보: ', signature); + + if (signature == null) { + return new ResponseDto( + ResponseCode.SIGNATURE_NOT_FOUND, + false, + '존재하지 않는 시그니처 입니다', + null, + ); + } + + // [2] 시그니처 삭제하기 + await this.signatureService.deleteSignature(signature); + + return new ResponseDto( + ResponseCode.DELETE_SIGNATURE_SUCCESS, + true, + '시그니처 삭제 성공', + null, + ); + } catch (error) { + console.log(error); + return new ResponseDto( + ResponseCode.SIGNATURE_DELETE_FAIL, + false, + '시그니처 삭제 실패', + null, + ); + } + } + + @Get('/like/:signatureId') // 시그니처에 좋아요한 사용자 목록 + @UseGuards(UserGuard) + async getSignatureLikeList( + @Req() req: Request, + @Param('signatureId') signatureId: number, + ): Promise> { + try { + const getLikeListDto: GetLikeListDto = + await this.signatureService.getSignatureLikeList( + req.user.id, + signatureId, + ); + + return new ResponseDto( + ResponseCode.GET_LIKE_SIGNATURE_PROFILES_SUCCESS, + true, + '시그니처 좋아요 목록 불러오기 성공', + getLikeListDto, + ); + } catch (error) { + return new ResponseDto( + ResponseCode.GET_LIKE_SIGNATURE_PROFILES_FAIL, + false, + '시그니처 좋아요 목록 불러오기 실패', + null, + ); + } + } +} diff --git a/src/signature/signature.module.ts b/src/signature/signature.module.ts new file mode 100644 index 0000000..66b80d4 --- /dev/null +++ b/src/signature/signature.module.ts @@ -0,0 +1,20 @@ +//signature.module.ts + +import { Module } from '@nestjs/common'; +import { SignatureService } from './signature.service'; +import { SignatureController } from './signature.controller'; +import { UserService } from '../user/user.service'; +import { S3UtilService } from '../utils/S3.service'; +import { SignatureCommentController } from './signature.comment.controller'; +import { SignatureCommentService } from './signature.comment.service'; + +@Module({ + controllers: [SignatureController, SignatureCommentController], + providers: [ + SignatureService, + SignatureCommentService, + UserService, + S3UtilService, + ], +}) +export class SignatureModule {} diff --git a/src/signature/signature.service.ts b/src/signature/signature.service.ts new file mode 100644 index 0000000..63e3d1c --- /dev/null +++ b/src/signature/signature.service.ts @@ -0,0 +1,472 @@ +// signature.service.ts + +import { + BadRequestException, + HttpException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { CreateSignatureDto } from './dto/signature/create-signature.dto'; +import { SignatureEntity } from './domain/signature.entity'; +import { HomeSignatureDto } from './dto/signature/home-signature.dto'; +import { UserEntity } from 'src/user/user.entity'; +import { SignaturePageEntity } from './domain/signature.page.entity'; +import { PageSignatureDto } from './dto/signature/page-signature.dto'; +import { DetailSignatureDto } from './dto/signature/detail-signature.dto'; +import { AuthorSignatureDto } from './dto/signature/author-signature.dto'; +import { HeaderSignatureDto } from './dto/signature/header-signature.dto'; +import { UserService } from '../user/user.service'; +import { SignatureLikeEntity } from './domain/signature.like.entity'; +import { GetLikeListDto } from './dto/like/get-like-list.dto'; +import { LikeProfileDto } from './dto/like/like-profile.dto'; +import { S3UtilService } from '../utils/S3.service'; +import { ResponsePageSignatureDto } from './dto/signature/response-page-signature.dto'; +import { NotificationEntity } from '../notification/notification.entity'; + +@Injectable() +export class SignatureService { + constructor( + private readonly userService: UserService, + private readonly s3Service: S3UtilService, + ) {} + + async createSignature( + createSignatureDto: CreateSignatureDto, + userId: number, + ): Promise { + try { + // [1] 시그니처 저장 + const signature: SignatureEntity = await SignatureEntity.createSignature( + createSignatureDto, + userId, + ); + + if (!signature) throw new BadRequestException(); + else { + // [2] 각 페이지 저장 + try { + for (const pageSignatureDto of createSignatureDto.pages) { + await this.saveSignaturePage(pageSignatureDto, signature); + } + return signature.id; + } catch (e) { + // 만약 페이지 저장 중에 오류 발생해 저장이 중단되면 시그니처도 삭제하도록 + await this.deleteSignature(signature); + console.log('Error on createSignatruePage: ', e); + throw e; + } + } + } catch (e) { + console.log('Error on createSignatrue: ', e); + throw e; + } + } + + async saveSignaturePage( + pageSignatureDto: PageSignatureDto, + signature: SignatureEntity, + ) { + const signaturePage: SignaturePageEntity = new SignaturePageEntity(); + + signaturePage.signature = signature; + signaturePage.content = pageSignatureDto.content; + signaturePage.location = pageSignatureDto.location; + signaturePage.page = pageSignatureDto.page; + + // 랜덤 이미지 키 생성 + const key = `signature/${this.s3Service.generateRandomImageKey( + 'signaturePage.png', + )}`; + + // Base64 이미지 업로드 + const uploadedImage = await this.s3Service.putObjectFromBase64( + key, + pageSignatureDto.image, + ); + console.log(uploadedImage); + + signaturePage.image = key; + + await signaturePage.save(); + } + + async homeSignature(userId: number): Promise { + try { + console.log('userId; ', userId); + return this.findMySignature(userId); + } catch (error) { + // 예외 처리 + console.error('Error on HomeSignature: ', error); + throw new HttpException('Internal Server Error', 500); + } + } + + async findMySignature(user_id: number): Promise { + const mySignatureList: HomeSignatureDto[] = []; + const signatures = await SignatureEntity.find({ + where: { user: { id: user_id } }, + order: { created: 'DESC' }, // 내가 작성한 시그니처 최신 순으로 보여주도록 + }); + + for (const signature of signatures) { + const homeSignature: HomeSignatureDto = new HomeSignatureDto(); + + homeSignature._id = signature.id; + homeSignature.title = signature.title; + homeSignature.date = signature.created; + + // 이미지 가져오기 + const imageKey = await SignaturePageEntity.findThumbnail(signature.id); + homeSignature.image = await this.s3Service.getImageUrl(imageKey); + + mySignatureList.push(homeSignature); + } + return mySignatureList; + } + + async checkIfLiked(user: UserEntity, signatureId: number): Promise { + const signatureLike = await SignatureLikeEntity.findOne({ + where: { + user: { id: user.id }, + signature: { id: signatureId }, + }, + }); + if (signatureLike) return true; + else return false; + } + + async detailSignature( + userId: number, + signatureId: number, + ): Promise { + try { + const detailSignatureDto: DetailSignatureDto = new DetailSignatureDto(); + + // [1] 시그니처 객체, 로그인 유저 객체 가져오기 + const signature: SignatureEntity = + await SignatureEntity.findSignatureById(signatureId); + if (signature == null) return null; + console.log('시그니처 정보: ', signature); + + const loginUser: UserEntity = await this.userService.findUserById(userId); + console.log('로그인한 유저 정보: ', loginUser); + + /****************************************/ + + // [2] 시그니처 작성자 정보 가져오기 + const authorDto: AuthorSignatureDto = new AuthorSignatureDto(); + + if (!signature.user.isQuit) { + // 유저가 탈퇴하지 않았다면 + authorDto._id = signature.user.id; + authorDto.name = signature.user.nickname; + + const image = await this.userService.getProfileImage(signature.user.id); + if (image == null) authorDto.image = null; + else authorDto.image = await this.s3Service.getImageUrl(image.imageKey); + + if (loginUser.id == signature.user.id) { + // 시그니처 작성자가 본인이면 is_followed == null + authorDto.is_followed = null; + } else { + // 해당 시그니처 작성자를 팔로우하고 있는지 확인 + authorDto.is_followed = await this.userService.checkIfFollowing( + loginUser, + signature.user.id, + ); + } + detailSignatureDto.author = authorDto; + } else { + // 해당 시그니처를 작성한 유저가 탈퇴한 경우 + console.log('작성자 유저가 존재하지 않습니다.'); + authorDto._id = null; + authorDto.name = null; + authorDto.image = null; + authorDto.is_followed = null; + detailSignatureDto.author = authorDto; + } + + /****************************************/ + + // [3] 시그니처 헤더 정보 담기 + const headerSignatureDto: HeaderSignatureDto = new HeaderSignatureDto(); + headerSignatureDto._id = signature.id; + headerSignatureDto.title = signature.title; + headerSignatureDto.like_cnt = signature.liked; + + // 발행일 가공하기 + const date = signature.created; + headerSignatureDto.date = await SignatureEntity.formatDateString(date); + + // 해당 시그니처 좋아요 눌렀는지 확인하기 + headerSignatureDto.is_liked = await this.checkIfLiked( + loginUser, + signatureId, + ); + + detailSignatureDto.header = headerSignatureDto; + + /****************************************/ + + // [4] 각 페이지 내용 가져오기 + const signaturePageDto: ResponsePageSignatureDto[] = []; + const pages: SignaturePageEntity[] = + await SignaturePageEntity.findSignaturePages(signatureId); + + for (const page of pages) { + const pageDto: ResponsePageSignatureDto = + new ResponsePageSignatureDto(); + pageDto._id = page.id; + pageDto.page = page.page; + pageDto.content = page.content; + pageDto.location = page.location; + + //이미지 가져오기 + pageDto.image = await this.s3Service.getImageUrl(page.image); + + signaturePageDto.push(pageDto); + } + detailSignatureDto.pages = signaturePageDto; + + return detailSignatureDto; + } catch (error) { + // 예외 처리 + console.error('Error on DetailSignature: ', error); + throw new HttpException('Internal Server Error', 500); + } + } + + async findIfAlreadyLiked( + userId: number, + signatureId: number, + ): Promise { + const signatureLike = await SignatureLikeEntity.findOne({ + where: { + user: { id: userId }, + signature: { id: signatureId }, + }, + }); + + if (signatureLike) return signatureLike; + else null; + } + + async addLikeOnSignature(userId: number, signatureId: number) { + // [1] 시그니처 객체, 로그인 유저 객체 가져오기 + const signature: SignatureEntity = await SignatureEntity.findSignatureById( + signatureId, + ); + console.log('시그니처 정보: ', signature); + + const loginUser: UserEntity = await this.userService.findUserById(userId); + console.log('로그인한 유저 정보: ', loginUser); + + // [2] 좋아요 테이블에 인스턴스 추가하기 + await SignatureLikeEntity.createLike(signature, loginUser); + + // [3] 해당 시그니처 좋아요 개수 추가하기 + signature.liked++; + await SignatureEntity.save(signature); + + // 알림 표시 + // Todo: 좋아요를 했다가 해제한 경우에 알림을 어떻게 처리할 것인가? + const notification = new NotificationEntity(); + notification.notificationReceiver = signature.user; + notification.notificationSender = loginUser; + notification.notificationTargetType = 'SIGNATURE'; + notification.notificationTargetId = signature.id; + notification.notificationTargetDesc = signature.title; + notification.notificationAction = 'LIKE'; + await notification.save(); + + return signature; + } + + async deleteLikeOnSignature( + signatureLike: SignatureLikeEntity, + signatureId: number, + ) { + // [1] 해당 좋아요 기록 삭제 + await SignatureLikeEntity.softRemove(signatureLike); + + // [2] 시그니처 좋아요 개수 -1 + const signature: SignatureEntity = await SignatureEntity.findSignatureById( + signatureId, + ); + signature.liked--; + await SignatureEntity.save(signature); + + return signature; + } + + async deleteSignature(signature) { + try { + // [1] 페이지부터 삭제 + const deleteSignaturePages: SignaturePageEntity[] = + await SignaturePageEntity.find({ + where: { signature: { id: signature.id } }, + }); + + for (const deletePage of deleteSignaturePages) { + await SignaturePageEntity.softRemove(deletePage); + } + + // [2] 시그니처 삭제 + await SignatureEntity.softRemove(signature); + } catch (error) { + console.log('Error on deleting Signature: ', error); + throw error; + } + } + + async patchSignature( + signatureId: number, + patchSignatureDto: CreateSignatureDto, + ) { + // [1] 시그니처 객체 가져오기 + const signature: SignatureEntity = await SignatureEntity.findSignatureById( + signatureId, + ); + if (signature == null) return null; + console.log('시그니처 정보: ', signature); + + // [2] 시그니처 수정 + signature.title = patchSignatureDto.title; + await SignatureEntity.save(signature); + + // [3] 기존 페이지 가져오기 + const originalSignaturePages: SignaturePageEntity[] = + await SignaturePageEntity.find({ + where: { signature: { id: signature.id } }, + }); + + // [4] 기존 페이지 수정 및 새로운 페이지 추가하기 + for (const patchedPage of patchSignatureDto.pages) { + if (!patchedPage._id) { + // id가 없으면 새로 추가할 페이지 + await this.saveSignaturePage(patchedPage, signature); + } + for (const originalPage of originalSignaturePages) { + if (patchedPage._id == originalPage.id) { + originalPage.content = patchedPage.content; + originalPage.location = patchedPage.location; + + // base64로 들어온 이미지면 디코딩해서 새롭게 저장 / 아니면 그대로 두기 + if ( + patchedPage.image.startsWith( + 'https://hereyou-cdn.kaaang.dev/signature/', + ) + ) { + // 이미지 그대로 들어왔다면 이미지를 수정할 필요 없음 + console.log(patchedPage._id, ': original Image - 수정할 필요 없음'); + } else { + // 새로운 이미지가 인코딩돼서 들어왔다면 해당 이미지를 새로 저장해야 + console.log( + patchedPage._id, + ': patched Image - 이미지키 수정 진행', + ); + + // 랜덤 이미지 키 생성 + const key = `signature/${this.s3Service.generateRandomImageKey( + 'signaturePage.png', + )}`; + + // Base64 이미지 업로드 + await this.s3Service.putObjectFromBase64(key, patchedPage.image); + + // 이미지 키 저장 + originalPage.image = key; + } + } + await SignaturePageEntity.save(originalPage); + } + } + return signatureId; + } + + async getSignatureLikeList( + userId: number, + signatureId: number, + ): Promise { + try { + const signature = await SignatureEntity.findSignatureById(signatureId); + if (!signature) { + throw new NotFoundException( + `Signature with ID ${signatureId} not found`, + ); + } + + const getLikeListDto: GetLikeListDto = new GetLikeListDto(); + + const signatureLikeEntities = + await SignatureLikeEntity.findSignatureLikes(signatureId); + + // 총 좋아요 개수 + getLikeListDto.liked = signatureLikeEntities.length; + + const likeProfileDtos: LikeProfileDto[] = []; + + for (const signatureLikeEntity of signatureLikeEntities) { + const likeProfileDto = new LikeProfileDto(); + + if (signatureLikeEntity.user) { + likeProfileDto._id = signatureLikeEntity.user.id; + likeProfileDto.introduction = signatureLikeEntity.user.introduction; + likeProfileDto.nickname = signatureLikeEntity.user.nickname; + + // 프로필 이미지 꺼내오기 + const image = await this.userService.getProfileImage( + signatureLikeEntity.user.id, + ); + if (image == null) likeProfileDto.image = null; + else { + const userImageKey = image.imageKey; + likeProfileDto.image = await this.s3Service.getImageUrl( + userImageKey, + ); + } + + // 만약 좋아요 누른 사용자가 본인이 아니라면 is_followed 값을 체크하고 본인이면 null로 보내준다. + if (signatureLikeEntity.user.id != userId) { + const loginUser = await this.userService.findUserById(userId); + likeProfileDto.is_followed = + await this.userService.checkIfFollowing( + loginUser, + signatureLikeEntity.user.id, + ); + } else likeProfileDto.is_followed = null; + likeProfileDtos.push(likeProfileDto); + } + } + getLikeListDto.profiles = likeProfileDtos; + + return getLikeListDto; + } catch (error) { + console.log('Error on GetSignatureLikeList: ', error); + throw error; + } + } + + async getMyRecentSignatures(userId: number, take: number) { + // 가장 최신 시그니처 반환 + // 1. 메이트 탐색의 기준이 될 장소 가져오기 = 사용자의 가장 최신 시그니처의 첫 번째 페이지 장소 + return await SignatureEntity.find({ + where: { + user: { id: userId }, + }, + order: { + created: 'DESC', // 'created'를 내림차순으로 정렬해서 가장 최근꺼 가져오기 + }, + take: take, // 최신 시그니처 가져오기 + }); + } + + async getSignatureCnt(userId: number): Promise { + // 시그니처 개수 반환 + return await SignatureEntity.count({ + where: { + user: { id: userId }, + }, + }); + } +} diff --git a/src/types/express.d.ts b/src/types/express.d.ts index 062ca01..7369421 100644 --- a/src/types/express.d.ts +++ b/src/types/express.d.ts @@ -4,4 +4,4 @@ declare namespace Express { id: number; }; } -} \ No newline at end of file +} diff --git a/src/user/optional.user.guard.ts b/src/user/optional.user.guard.ts new file mode 100644 index 0000000..6e16b2c --- /dev/null +++ b/src/user/optional.user.guard.ts @@ -0,0 +1,47 @@ +import Express from 'express'; +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import * as jsonwebtoken from 'jsonwebtoken'; +import { IReqUser } from './user.dto'; +import { UserEntity } from './user.entity'; + +@Injectable() +export class OptionalUserGuard implements CanActivate { + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const authorization = request.headers['authorization']?.split(' '); + + if ( + authorization && + authorization.length === 2 && + authorization[0] === 'Bearer' + ) { + const token = authorization[1]; + + try { + request.user = jsonwebtoken.verify( + token, + process.env.JWT_SECRET, + ) as IReqUser; + + // 사용자 검증 + const isValidUser = await UserEntity.findOne({ + where: { + id: request.user.id, + isQuit: false, + }, + }); + + if (!isValidUser) { + return false; + } + } catch (error) { + return false; + } + } else { + // 토큰이 없는 경우 + request.user = null; + } + + return true; + } +} diff --git a/src/user/user.controller.ts b/src/user/user.controller.ts index b934774..077a424 100644 --- a/src/user/user.controller.ts +++ b/src/user/user.controller.ts @@ -1,5 +1,17 @@ -import { Body, Controller, Post } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Get, + Post, + Query, + Req, + UseGuards, +} from '@nestjs/common'; import { UserService } from './user.service'; +import { IUserProfile } from './user.dto'; +import { UserGuard } from './user.guard'; +import { Request } from 'express'; @Controller('user') export class UserController { @@ -9,4 +21,58 @@ export class UserController { Login(@Body('email') email: string, @Body('password') password: string) { return this.userService.Login(email, password); } + + @Post('/login/oauth') + SNSLogin( + @Body('type') type: 'KAKAO' | 'GOOGLE', + @Body('token') token: string, + @Body('redirect_uri') redirectUrl: string, + ) { + return this.userService.SNSLogin(type, token, redirectUrl); + } + + @Post('/profile') + @UseGuards(UserGuard) + UpdateProfile(@Body() body: Partial, @Req() req: Request) { + return this.userService.updateUserProfile(req.user.id, body); + } + + @Get('/profile') + @UseGuards(UserGuard) + GetUserProfile(@Req() req: Request) { + return this.userService.GetUserProfile(req.user.id); + } + + @Post('/profile/nickname') + @UseGuards(UserGuard) + UpdateNickname(@Body('nickname') nickname: string, @Req() req: Request) { + return this.userService.updateUserProfile(req.user.id, { nickname }); + } + + @Post('/profile/intro') + @UseGuards(UserGuard) + UpdateIntroduction(@Body('intro') introduction: string, @Req() req: Request) { + return this.userService.updateUserProfile(req.user.id, { introduction }); + } + + @Post('/profile/visibility') + @UseGuards(UserGuard) + UpdateUserVisibility( + @Body('visibility') visibility: 'PRIVATE' | 'PUBLIC' | 'MATE', + @Req() req: Request, + ) { + return this.userService.updateUserVisibility(req.user.id, visibility); + } + + @Delete('/profile/delete') + @UseGuards(UserGuard) + DeleteAccount(@Req() req: Request) { + return this.userService.deleteAccount(req.user.id); + } + + @Get('/diaries') + @UseGuards(UserGuard) + ListDiaries(@Req() req: Request, @Query('cursor') cursor: string) { + return this.userService.listDiaries(req.user.id, cursor); + } } diff --git a/src/user/user.dto.ts b/src/user/user.dto.ts index 1bf2737..0d19ad7 100644 --- a/src/user/user.dto.ts +++ b/src/user/user.dto.ts @@ -1,3 +1,8 @@ export interface IReqUser { id: number; -} \ No newline at end of file +} + +export interface IUserProfile { + nickname: string; + introduction: string; +} diff --git a/src/user/user.entity.ts b/src/user/user.entity.ts index a2150fd..b03832d 100644 --- a/src/user/user.entity.ts +++ b/src/user/user.entity.ts @@ -3,12 +3,22 @@ import { Column, CreateDateColumn, DeleteDateColumn, - Entity, OneToMany, OneToOne, + Entity, + OneToMany, + OneToOne, PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; import { UserProfileImageEntity } from './user.profile.image.entity'; import { UserFollowingEntity } from './user.following.entity'; +import { SignatureEntity } from '../signature/domain/signature.entity'; +import { SignatureLikeEntity } from '../signature/domain/signature.like.entity'; +import { RuleInvitationEntity } from '../rule/domain/rule.invitation.entity'; +import { CommentEntity } from 'src/comment/domain/comment.entity'; +import { JourneyEntity } from 'src/journey/model/journey.entity'; +import { NotFoundException } from '@nestjs/common'; +import { BaseResponse } from 'src/response/response.status'; +import { SignatureCommentEntity } from '../signature/domain/signature.comment.entity'; @Entity() export class UserEntity extends BaseEntity { @@ -24,8 +34,14 @@ export class UserEntity extends BaseEntity { @Column() password: string; + @Column() + nickname: string; + @Column({ type: 'text' }) - bio: string; + introduction: string; + + @Column({ type: 'enum', enum: ['PUBLIC', 'PRIVATE', 'MATE'] }) + visibility: 'PUBLIC' | 'PRIVATE' | 'MATE'; @Column() age: number; @@ -39,15 +55,44 @@ export class UserEntity extends BaseEntity { @Column() oauthToken: string; - @OneToOne(() => UserProfileImageEntity, profileImage => profileImage.user) + @Column({ default: false }) + isQuit: boolean; + + @OneToOne(() => UserProfileImageEntity, (profileImage) => profileImage.user, { + cascade: true, + }) profileImage: UserProfileImageEntity; - @OneToMany(() => UserFollowingEntity, following => following.user) + @OneToMany(() => UserFollowingEntity, (following) => following.user) following: UserFollowingEntity[]; - @OneToMany(() => UserFollowingEntity, followed => followed.followUser) + @OneToMany(() => UserFollowingEntity, (followed) => followed.followUser) follower: UserFollowingEntity[]; + @OneToMany(() => SignatureEntity, (signature) => signature.user) + signatures: SignatureEntity[]; + + @OneToMany( + () => SignatureLikeEntity, + (signatureLike) => signatureLike.signature, + ) + likes: SignatureLikeEntity[]; + + @OneToMany(() => RuleInvitationEntity, (invitation) => invitation.member) + ruleParticipate: RuleInvitationEntity[]; + + @OneToMany(() => CommentEntity, (comment) => comment.user) + comments: CommentEntity[]; + + @OneToMany(() => JourneyEntity, (journey) => journey.user) + journeys: JourneyEntity[]; + + @OneToMany( + () => SignatureCommentEntity, + (signatureComment) => signatureComment.user, + ) + signatureComments: SignatureCommentEntity[]; + @CreateDateColumn() created: Date; @@ -56,4 +101,17 @@ export class UserEntity extends BaseEntity { @DeleteDateColumn() deleted: Date; -} \ No newline at end of file + + static async findExistUser(userId) { + const user = await UserEntity.findOne({ + where: { + id: userId, + isQuit: false, + }, + }); + if (!user) { + throw new NotFoundException(BaseResponse.USER_NOT_FOUND); + } + return user; + } +} diff --git a/src/user/user.following.entity.ts b/src/user/user.following.entity.ts index 65ed9b7..65ad120 100644 --- a/src/user/user.following.entity.ts +++ b/src/user/user.following.entity.ts @@ -1,4 +1,13 @@ -import { BaseEntity, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { + BaseEntity, + CreateDateColumn, + DeleteDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; import { UserEntity } from './user.entity'; @Entity() @@ -7,10 +16,19 @@ export class UserFollowingEntity extends BaseEntity { id: number; @JoinColumn() - @ManyToOne(() => UserEntity, user => user.following) + @ManyToOne(() => UserEntity, (user) => user.following) user: UserEntity; @JoinColumn() - @ManyToOne(() => UserEntity, user => user.follower) + @ManyToOne(() => UserEntity, (user) => user.follower) followUser: UserEntity; -} \ No newline at end of file + + @CreateDateColumn() + created: Date; + + @UpdateDateColumn() + updated: Date; + + @DeleteDateColumn() + deleted: Date; +} diff --git a/src/user/user.guard.ts b/src/user/user.guard.ts new file mode 100644 index 0000000..6ecb9b0 --- /dev/null +++ b/src/user/user.guard.ts @@ -0,0 +1,46 @@ +import Express from 'express'; +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import * as jsonwebtoken from 'jsonwebtoken'; +import { IReqUser } from './user.dto'; +import { UserEntity } from './user.entity'; + +@Injectable() +export class UserGuard implements CanActivate { + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const authorization = request.headers['authorization']?.split(' '); + + if ( + authorization && + authorization.length === 2 && + authorization[0] === 'Bearer' + ) { + const token = authorization[1]; + + try { + request.user = jsonwebtoken.verify( + token, + process.env.JWT_SECRET, + ) as IReqUser; + + // 사용자 검증 + const isValidUser = await UserEntity.findOne({ + where: { + id: request.user.id, + isQuit: false, + }, + }); + + if (!isValidUser) { + return false; + } + } catch (error) { + return false; + } + } else { + return false; + } + + return true; + } +} diff --git a/src/user/user.module.ts b/src/user/user.module.ts index e21d51f..4c741a9 100644 --- a/src/user/user.module.ts +++ b/src/user/user.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { UserService } from './user.service'; import { UserController } from './user.controller'; +import { S3Module } from '../utils/S3.module'; @Module({ + imports: [S3Module], controllers: [UserController], providers: [UserService], }) diff --git a/src/user/user.profile.image.entity.ts b/src/user/user.profile.image.entity.ts index 8ad1f51..c103f05 100644 --- a/src/user/user.profile.image.entity.ts +++ b/src/user/user.profile.image.entity.ts @@ -1,8 +1,11 @@ import { - BaseEntity, Column, + BaseEntity, + Column, CreateDateColumn, DeleteDateColumn, - Entity, JoinColumn, OneToOne, + Entity, + JoinColumn, + OneToOne, PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; @@ -14,7 +17,7 @@ export class UserProfileImageEntity extends BaseEntity { id: number; @JoinColumn() - @OneToOne(() => UserEntity, user => user.profileImage) + @OneToOne(() => UserEntity, (user) => user.profileImage) user: UserEntity; @Column() @@ -28,4 +31,14 @@ export class UserProfileImageEntity extends BaseEntity { @DeleteDateColumn() deleted: Date; -} \ No newline at end of file + + static async findImageKey(userEntity): Promise { + const imageEntity: UserProfileImageEntity = + await UserProfileImageEntity.findOneOrFail({ + where: { user: userEntity }, + }); + const imageKey = imageEntity.imageKey; + + return imageKey; + } +} diff --git a/src/user/user.service.ts b/src/user/user.service.ts index c304b5c..c8bd25e 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -1,11 +1,25 @@ -import { HttpException, Injectable } from '@nestjs/common'; -import bcrypt from 'bcrypt'; -import jsonwebtoken from 'jsonwebtoken'; +import { HttpException, Injectable, Logger } from '@nestjs/common'; +import * as bcrypt from 'bcrypt'; +import * as jsonwebtoken from 'jsonwebtoken'; +import fetch from 'node-fetch'; import { UserEntity } from './user.entity'; -import { IReqUser } from './user.dto'; +import { IReqUser, IUserProfile } from './user.dto'; +import { UserProfileImageEntity } from './user.profile.image.entity'; +import { ResponseDto } from '../response/response.dto'; +import { ResponseCode } from '../response/response-code.enum'; +import { UserFollowingEntity } from './user.following.entity'; +import { LessThan } from 'typeorm'; +import { RuleInvitationEntity } from '../rule/domain/rule.invitation.entity'; +import * as md5 from 'md5'; +import { DiaryEntity } from '../diary/models/diary.entity'; +import { S3UtilService } from '../utils/S3.service'; @Injectable() export class UserService { + private readonly logger: Logger = new Logger(UserService.name); + + constructor(private s3Service: S3UtilService) {} + private _hashPassword(password: string): string { return bcrypt.hashSync(password, 10); } @@ -20,10 +34,74 @@ export class UserService { }); } + private objectToQueryString(obj: Record) { + return Object.entries(obj) + .map(([key, value]) => `${key}=${encodeURIComponent(value.toString())}`) + .join('&'); + } + + private async getKakaoToken(code: string, redirectUrl: string) { + const response = await fetch(`https://kauth.kakao.com/oauth/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8', + }, + body: this.objectToQueryString({ + grant_type: 'authorization_code', + client_id: process.env.KAKAO_CLIENT_ID, + redirect_uri: redirectUrl, + code, + }), + }); + + return await response.json(); + } + + private async getKakaoInformation(accessToken: string) { + const response = await fetch( + `https://kapi.kakao.com/v2/user/me?${this.objectToQueryString({ + property_keys: JSON.stringify([ + 'kakao_account.profile', + 'kakao_account.email', + 'kakao_account.name', + ]), + })}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-type': 'application/x-www-form-urlencoded;charset=utf-8', + }, + }, + ); + + return await response.json(); + } + + private async getGoogleInformation(accessToken: string) { + const response = await fetch( + 'https://www.googleapis.com/oauth2/v3/userinfo', + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + return await response.json(); + } + + private async downloadImage(url: string) { + const response = await fetch(url); + + return await response.buffer(); + } + async Login(email: string, password: string) { + console.log(email, password); const user = await UserEntity.findOne({ where: { email: email.toString() ?? '', + isQuit: false, }, }); @@ -31,9 +109,9 @@ export class UserService { throw new HttpException('Invalid credentials', 403); } - if (!this._comparePassword(password, user.password)) { - throw new HttpException('Invalid credentials', 403); - } + // if (!this._comparePassword(password, user.password)) { + // throw new HttpException('Invalid credentials', 403); + // } return { success: true, @@ -42,4 +120,571 @@ export class UserService { }), }; } + + async SNSLogin(type: 'KAKAO' | 'GOOGLE', code: string, redirectUrl: string) { + if (type === 'KAKAO') { + // 인가 코드 받기 + const authToken = await this.getKakaoToken(code, redirectUrl); + if (authToken.error) { + return new ResponseDto( + ResponseCode.UNKNOWN_AUTHENTICATION_ERROR, + false, + '인가 코드가 유효하지 않습니다', + null, + ); + } + + // 사용자 정보 받기 + const kakaoInfo = await this.getKakaoInformation(authToken.access_token); + + const userId = kakaoInfo.id; + const userProfile = kakaoInfo.kakao_account.profile; + const userEmail = kakaoInfo.kakao_account.email; + + // 사용자 정보로 DB 조회 + let userEntity = await UserEntity.findOne({ + where: { + oauthType: 'KAKAO', + oauthToken: userId.toString(), + isQuit: false, + }, + relations: { + profileImage: true, + }, + }); + + let isNewUser = false; + if (!userEntity) { + isNewUser = true; + userEntity = new UserEntity(); + userEntity.oauthType = 'KAKAO'; + userEntity.oauthToken = userId.toString(); + + userEntity.introduction = ''; + userEntity.visibility = 'PUBLIC'; + userEntity.name = ''; + userEntity.age = 0; + + // 중복 닉네임 확인 + const existingNickname = await UserEntity.count({ + where: { + nickname: userProfile?.nickname, + }, + }); + if (existingNickname > 0) { + // 난수 추가 + const availableChars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + const randomStringLength = 5; + let randomString = ''; + for (let i = 0; i < randomStringLength; i++) { + randomString += availableChars.charAt( + Math.floor(Math.random() * availableChars.length), + ); + } + + userEntity.nickname = `${userProfile?.nickname}_${randomString}`; + } else { + userEntity.nickname = userProfile?.nickname; + } + } + + userEntity.email = userEmail; + userEntity.password = ''; + + if (userProfile?.profile_image_url) { + const urlHash = md5(userProfile.profile_image_url); + const extension = userProfile.profile_image_url.split('.').pop(); + const profileFilename = `profile/kakao_${urlHash}.${extension}`; + + if ( + !userEntity.profileImage || + userEntity.profileImage.imageKey !== profileFilename + ) { + const profileImageEntity = new UserProfileImageEntity(); + profileImageEntity.imageKey = urlHash; + + const profileImageFile = await this.downloadImage( + userProfile.profile_image_url, + ); + await this.s3Service.putObject(profileFilename, profileImageFile); + + profileImageEntity.imageKey = profileFilename; + if (userEntity.profileImage) { + userEntity.profileImage = null; + await userEntity.save(); + } + + await profileImageEntity.save(); + + userEntity.profileImage = profileImageEntity; + } + } + + await userEntity.save(); + + return { + status: 200, + success: true, + message: '로그인 성공', + token: this._generateToken({ + id: userEntity.id, + }), + register_required: isNewUser, + }; + } else if (type === 'GOOGLE') { + // 사용자 정보 받기 + const googleInfo = await this.getGoogleInformation(code); + + const userId = googleInfo.sub; + const userEmail = googleInfo.email; + + // 사용자 정보로 DB 조회 + let userEntity = await UserEntity.findOne({ + where: { + oauthType: 'GOOGLE', + oauthToken: userId.toString(), + isQuit: false, + }, + relations: { + profileImage: true, + }, + }); + + let isNewUser = false; + if (!userEntity) { + isNewUser = true; + userEntity = new UserEntity(); + userEntity.oauthType = 'GOOGLE'; + userEntity.oauthToken = userId.toString(); + + userEntity.introduction = ''; + userEntity.visibility = 'PUBLIC'; + userEntity.name = ''; + userEntity.age = 0; + + // 중복 닉네임 확인 + const existingNickname = await UserEntity.count({ + where: { + nickname: googleInfo.name, + }, + }); + if (existingNickname > 0) { + // 난수 추가 + const availableChars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + const randomStringLength = 5; + let randomString = ''; + for (let i = 0; i < randomStringLength; i++) { + randomString += availableChars.charAt( + Math.floor(Math.random() * availableChars.length), + ); + } + + userEntity.nickname = `${googleInfo.name}_${randomString}`; + } else { + userEntity.nickname = googleInfo.name; + } + } + + userEntity.email = userEmail; + userEntity.password = ''; + + if (googleInfo.picture) { + const urlHash = md5(googleInfo.picture); + const profileFilename = `profile/google_${urlHash}`; + + if ( + !userEntity.profileImage || + userEntity.profileImage.imageKey !== profileFilename + ) { + const profileImageEntity = new UserProfileImageEntity(); + profileImageEntity.imageKey = urlHash; + + const profileImageFile = await this.downloadImage(googleInfo.picture); + await this.s3Service.putObject(profileFilename, profileImageFile); + + profileImageEntity.imageKey = profileFilename; + if (userEntity.profileImage) { + userEntity.profileImage = null; + await userEntity.save(); + } + + await profileImageEntity.save(); + + userEntity.profileImage = profileImageEntity; + } + } + + await userEntity.save(); + + return { + status: 200, + success: true, + message: '로그인 성공', + token: this._generateToken({ + id: userEntity.id, + }), + register_required: isNewUser, + }; + } else { + return new ResponseDto( + ResponseCode.INTERNAL_SERVEr_ERROR, + false, + '잘못된 요청입니다', + null, + ); + } + } + + async checkIfFollowing( + user: UserEntity, + targetUserId: number, + ): Promise { + // user가 targetUser를 팔로우하고 있는지 확인 + + const isFollowing = await UserFollowingEntity.findOne({ + where: { + user: { id: user.id }, + followUser: { id: targetUserId }, + }, + }); + + if (isFollowing) return true; + else return false; + } + + async findUserById(userId: number): Promise { + try { + const user: UserEntity = await UserEntity.findOne({ + where: { id: userId }, + relations: ['profileImage'], + }); + return user; + } catch (error) { + console.log('Error on findUserById: ', error); + throw error; + } + } + + async getProfileImage(userId: number) { + try { + const profileImageEntity = await UserProfileImageEntity.findOne({ + where: { user: { id: userId } }, + }); + + console.log('겟프로필이미지: ', profileImageEntity); + return profileImageEntity; + } catch (error) { + console.log('Error on getProfileImage: ' + error); + } + } + + async updateUserProfile(userId: number, profile: Partial) { + try { + const user = await UserEntity.findOne({ + where: { + id: Number(userId), + }, + }); + + if (profile.introduction !== undefined) { + user.introduction = profile.introduction; + } + if (profile.nickname !== undefined) { + // Todo: 닉네임 중복 체크를 트랜잭션으로 처리하라 + const existingNickname = await UserEntity.count({ + where: { + nickname: profile.nickname.toString(), + }, + }); + + if (existingNickname > 0) { + return new ResponseDto( + ResponseCode.NICKNAME_DUPLICATION, + false, + '중복된 닉네임 존재', + null, + ); + } + + user.nickname = profile.nickname; + } + + await user.save(); + + return new ResponseDto( + ResponseCode.UPDATE_PROFILE_SUCCESS, + true, + '추가정보 입력 성공', + null, + ); + } catch (error) { + this.logger.error(error); + + if (error instanceof HttpException) { + throw error; + } + return new ResponseDto( + ResponseCode.INTERNAL_SERVEr_ERROR, + false, + '서버 내부 오류', + null, + ); + } + } + + async getFollowingList(userId: number) { + try { + return await UserFollowingEntity.find({ + where: { + user: { id: userId, isQuit: false }, + followUser: { isQuit: false }, + }, + relations: { followUser: { profileImage: true } }, + }); + } catch (error) { + console.log('Error on getFollowingList: ' + error); + } + } + + async getFollowerList(userId: number) { + try { + return await UserFollowingEntity.find({ + where: { + followUser: { id: userId, isQuit: false }, + user: { isQuit: false }, + }, + relations: { user: { profileImage: true } }, + }); + } catch (error) { + console.log('Error on getFollowingList: ' + error); + } + } + async updateUserVisibility( + userId: number, + visibility: 'PUBLIC' | 'PRIVATE' | 'MATE', + ) { + try { + const user = await UserEntity.findOne({ + where: { + id: Number(userId), + }, + }); + + user.visibility = visibility; + await user.save(); + + return new ResponseDto( + ResponseCode.UPDATE_PROFILE_SUCCESS, + true, + '공개범위 설정 성공', + null, + ); + } catch (error) { + this.logger.error(error); + + if (error instanceof HttpException) { + throw error; + } + return new ResponseDto( + ResponseCode.INTERNAL_SERVEr_ERROR, + false, + '서버 내부 오류', + null, + ); + } + } + + async deleteAccount(userId: number) { + try { + const user = await UserEntity.findOne({ + where: { + id: Number(userId), + }, + }); + + user.name = '탈퇴한 사용자'; + user.email = ''; + user.password = ''; + user.nickname = '탈퇴한 사용자'; + user.introduction = '탈퇴한 사용자입니다.'; + user.age = 0; + user.gender = 'UNKNOWN'; + user.profileImage = null; + user.oauthToken = ''; + user.isQuit = true; + await user.save(); + + const followings = await UserFollowingEntity.find({ + where: [{ user: { id: userId } }, { followUser: { id: userId } }], + }); + + for (const following of followings) { + console.log('삭제될 팔로잉 테이블 ID : ', following.id); + await following.softRemove(); + } + + const ruleInvitations = await RuleInvitationEntity.find({ + where: { member: { id: userId } }, + }); + + for (const invitation of ruleInvitations) { + console.log('삭제될 규칙 초대 테이블 ID : ', invitation.id); + await invitation.softRemove(); + } + + return new ResponseDto( + ResponseCode.DELETE_ACCOUNT_SUCCESS, + true, + '탈퇴 성공', + null, + ); + } catch (error) { + this.logger.error(error); + + if (error instanceof HttpException) { + throw error; + } + return new ResponseDto( + ResponseCode.INTERNAL_SERVEr_ERROR, + false, + '서버 내부 오류', + null, + ); + } + } + + async findFollowingMates(userId: number) { + try { + // userId에 해당하는 유저가 팔로우하고 있는 메이트 목록 리턴 + const followingMates = await UserEntity.find({ + where: { + follower: { user: { id: userId } }, + isQuit: false, // 탈퇴한 메이트는 팔로잉 목록에서 제외 + }, + }); + return followingMates; + } catch (error) { + console.log('Error on findFollowingMates: ', error); + throw error; + } + } + + async isAlreadyFollowing(userId: number, followingId: number) { + const userEntity = await this.findUserById(userId); + const followingEntity = await this.findUserById(followingId); + console.log('현재 로그인한 사용자 : ', userEntity.id); + console.log('팔로우 대상 사용자 : ', followingEntity.id); + + const isFollowing = await UserFollowingEntity.findOne({ + where: { + user: { id: userId }, + followUser: { id: followingId }, + }, + }); + + // 팔로우 관계 : true 반환 + return !!isFollowing; + } + + async checkAlreadyMember(userId: number, ruleID: number) { + const rule = await RuleInvitationEntity.findOne({ + where: { member: { id: userId }, rule: { id: ruleID } }, + }); + // 이미 규칙 멤버인 경우 : true 반환 + console.log('rule : ', rule); + return !!rule; + } + + async listDiaries(userId: number, cursor?: string, take = 10) { + if (!cursor || cursor === '') { + cursor = undefined; + } + + // user -> journey -> schedules -> diary -> diaryImage + const diaries = await DiaryEntity.find({ + where: { + schedule: { + journey: { + user: { + id: userId, + }, + }, + }, + id: cursor ? LessThan(Number(cursor)) : undefined, + }, + relations: { + image: true, + schedule: { + journey: { + user: true, + }, + }, + }, + order: { + id: 'DESC', + }, + take, + }); + + return new ResponseDto( + ResponseCode.GET_DIARY_SUCCESS, + true, + '일지 목록 조회 성공', + { + diaries: await Promise.all( + diaries.map(async (diary) => ({ + scheduleId: diary.schedule.id, + id: diary.id, + title: diary.title, + place: diary.place, + weather: diary.weather, + mood: diary.mood, + content: diary.content, + date: diary.schedule.date, + diary_image: diary.image + ? { + id: diary.image.id, + url: await this.s3Service.getImageUrl(diary.image.imageUrl), + } + : null, + })), + ), + }, + ); + } + + async GetUserProfile(userId: number) { + const user = await UserEntity.findOne({ + where: { + id: userId, + }, + relations: { + profileImage: true, + follower: true, + following: true, + }, + }); + + const profileImage = user.profileImage + ? await this.s3Service.getImageUrl(user.profileImage.imageKey) + : null; + + return new ResponseDto( + ResponseCode.GET_USER_PROFILE_SUCCESS, + true, + '유저 프로필 조회 성공', + { + user: { + id: user.id, + email: user.email, + nickname: user.nickname, + introduction: user.introduction, + visibility: user.visibility, + profileImage: profileImage, + followers: user.follower.length, + followings: user.following.length, + }, + }, + ); + } } diff --git a/src/utils/S3.controller.ts b/src/utils/S3.controller.ts new file mode 100644 index 0000000..8c2f049 --- /dev/null +++ b/src/utils/S3.controller.ts @@ -0,0 +1,23 @@ +// S3.controller.ts + +import { Body, Controller, Get } from '@nestjs/common'; +import { S3UtilService } from './S3.service'; + +@Controller('image') +export class S3UtilController { + constructor(private readonly s3Service: S3UtilService) {} + + @Get('/signature') + GetPresignedUrlForSignature() { + // 시그니처 이미지 업로드 요청시 + return this.s3Service.GetPresignedUrlForSignature(); + } + + @Get('/test') + TestImageUrlWithKey( + //presigned URL 잘 보내졌나 테스트용 + @Body('key') key: string, + ) { + return this.s3Service.TestImageUrlWithKey(key); + } +} diff --git a/src/utils/S3.module.ts b/src/utils/S3.module.ts new file mode 100644 index 0000000..b2e5bb6 --- /dev/null +++ b/src/utils/S3.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { S3UtilService } from './S3.service'; +import { S3UtilController } from './S3.controller'; + +@Module({ + controllers: [S3UtilController], + providers: [S3UtilService], + exports: [S3UtilService], +}) +export class S3Module {} diff --git a/src/utils/S3.presignedUrl.dto.ts b/src/utils/S3.presignedUrl.dto.ts new file mode 100644 index 0000000..824a24a --- /dev/null +++ b/src/utils/S3.presignedUrl.dto.ts @@ -0,0 +1,6 @@ +// S3.presignedUrl.dto.ts + +export class S3PresignedUrlDto { + key: string; + url: string; +} diff --git a/src/utils/S3.service.ts b/src/utils/S3.service.ts new file mode 100644 index 0000000..eb8e1e0 --- /dev/null +++ b/src/utils/S3.service.ts @@ -0,0 +1,81 @@ +import * as S3 from 'aws-sdk/clients/s3.js'; +import { v4 as uuidv4 } from 'uuid'; +import { Injectable } from '@nestjs/common'; +import { S3PresignedUrlDto } from './S3.presignedUrl.dto'; + +@Injectable() +export class S3UtilService { + private readonly s3 = new S3({ + signatureVersion: 'v4', + endpoint: process.env.S3_ENDPOINT, + region: process.env.AWS_REGION, + credentials: { + accessKeyId: process.env.S3_ACCESS_KEY, + secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, + }, + }); + + public async listObjects() { + return this.s3 + .listObjectsV2({ Bucket: process.env.S3_BUCKET_NAME }) + .promise(); + } + + public async putObject(key: string, body: Buffer) { + return this.s3 + .putObject({ + Bucket: process.env.S3_BUCKET_NAME, + Key: key, + Body: body, + }) + .promise(); + } + + public async putObjectFromBase64(key: string, body: string) { + return this.s3 + .putObject({ + Bucket: process.env.S3_BUCKET_NAME, + Key: key, + Body: Buffer.from(body, 'base64'), + }) + .promise(); + } + + public async getPresignedUrl(key: string) { + return this.s3.getSignedUrlPromise('putObject', { + Bucket: process.env.S3_BUCKET_NAME, + Key: key, + Expires: 60, + }); + } + + public async getImageUrl(key: string) { + return `${process.env.S3_PUBLIC_URL}${key}`; + } + + public generateRandomImageKey(originalName: string) { + const ext = originalName.split('.').pop(); + console.log(ext); + const uuid = uuidv4(); + + return `${uuid}.${ext}`; + } + + public async GetPresignedUrlForSignature(): Promise { + const s3PresignedUrlDto: S3PresignedUrlDto = new S3PresignedUrlDto(); + + // 이미지 키 생성: 프론트에서는 업로드 후 백엔드에 키값을 보내줘야함 + s3PresignedUrlDto.key = `signature/${this.generateRandomImageKey( + 'signature.png', + )}`; + + // 프론트에서 이미지를 업로드할 presignedUrl + s3PresignedUrlDto.url = await this.getPresignedUrl(s3PresignedUrlDto.key); + + return s3PresignedUrlDto; + } + + async TestImageUrlWithKey(key: string) { + return await this.getImageUrl(key); + } +}