diff --git a/package-lock.json b/package-lock.json index 7ca743e..d21c4e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,16 +17,21 @@ "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.0.0", "@nestjs/platform-socket.io": "^10.4.15", - "@nestjs/swagger": "^8.1.0", + "@nestjs/swagger": "^8.1.1", "@prisma/client": "^6.1.0", "axios": "^1.7.9", "bcrypt": "^5.1.1", + "cheerio": "^1.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "cookie-parser": "^1.4.7", + "date-fns": "^4.1.0", + "dayjs": "^1.11.13", "dotenv": "^16.4.7", + "file-type": "^16.5.4", "ioredis": "^5.4.2", "jsonwebtoken": "^9.0.2", + "mime": "^4.0.6", "passport": "^0.7.0", "passport-github": "^1.1.0", "passport-github2": "^0.1.12", @@ -46,6 +51,7 @@ "@types/cookie-parser": "^1.4.8", "@types/express": "^5.0.0", "@types/jest": "^29.5.2", + "@types/multer": "^1.4.12", "@types/node": "^20.3.1", "@types/passport-github2": "^1.2.9", "@types/passport-google-oauth20": "^2.0.16", @@ -2809,9 +2815,9 @@ "license": "MIT" }, "node_modules/@nestjs/swagger": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-8.1.0.tgz", - "integrity": "sha512-8hzH+r/31XshzXHC9vww4T0xjDAxMzvOaT1xAOvvY1LtXTWyNRCUP2iQsCYJOnnMrR+vydWjvRZiuB3hdvaHxA==", + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-8.1.1.tgz", + "integrity": "sha512-5Mda7H1DKnhKtlsb0C7PYshcvILv8UFyUotHzxmWh0G65Z21R3LZH/J8wmpnlzL4bmXIfr42YwbEwRxgzpJ5sQ==", "license": "MIT", "dependencies": { "@microsoft/tsdoc": "^0.15.0", @@ -3846,6 +3852,12 @@ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "license": "MIT" }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -4122,6 +4134,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/multer": { + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.12.tgz", + "integrity": "sha512-pQ2hoqvXiJt2FP9WQVLPRO+AmiIm/ZYkavPlIQnx282u4ZrVdztx0pkh3jjpQt0Kz+YI0YhSG264y08UJKoUQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "20.17.10", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.10.tgz", @@ -5297,6 +5319,12 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/bowser": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", @@ -5561,6 +5589,48 @@ "dev": true, "license": "MIT" }, + "node_modules/cheerio": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0.tgz", + "integrity": "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "encoding-sniffer": "^0.2.0", + "htmlparser2": "^9.1.0", + "parse5": "^7.1.2", + "parse5-htmlparser2-tree-adapter": "^7.0.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^6.19.5", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=18.17" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -6038,6 +6108,50 @@ "node": ">= 8" } }, + "node_modules/css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", @@ -6224,6 +6338,61 @@ "node": ">=6.0.0" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "16.4.7", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", @@ -6332,6 +6501,31 @@ "node": ">= 0.8" } }, + "node_modules/encoding-sniffer": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.0.tgz", + "integrity": "sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/encoding-sniffer/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/engine.io": { "version": "6.6.2", "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.2.tgz", @@ -6402,6 +6596,18 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -7074,6 +7280,23 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-type": { + "version": "16.5.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz", + "integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==", + "license": "MIT", + "dependencies": { + "readable-web-to-node-stream": "^3.0.0", + "strtok3": "^6.2.4", + "token-types": "^4.1.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, "node_modules/filelist": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", @@ -7719,6 +7942,25 @@ "dev": true, "license": "MIT" }, + "node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -7790,7 +8032,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -9510,15 +9751,18 @@ } }, "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.0.6.tgz", + "integrity": "sha512-4rGt7rvQHBbaSOF9POGkk1ocRP16Md1x36Xma8sz8h8/vfCUI2OtEIeCqe4Ofes853x4xDoPiFLIT47J5fI/7A==", + "funding": [ + "https://github.com/sponsors/broofa" + ], "license": "MIT", "bin": { - "mime": "cli.js" + "mime": "bin/cli.js" }, "engines": { - "node": ">=4" + "node": ">=16" } }, "node_modules/mime-db": { @@ -9792,6 +10036,18 @@ "set-blocking": "^2.0.0" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/oauth": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.0.tgz", @@ -9999,6 +10255,43 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz", + "integrity": "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==", + "license": "MIT", + "dependencies": { + "entities": "^4.5.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -10180,6 +10473,19 @@ "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" }, + "node_modules/peek-readable": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz", + "integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -10560,6 +10866,36 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/readable-web-to-node-stream": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz", + "integrity": "sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/readable-web-to-node-stream/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==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -11032,6 +11368,18 @@ "node": ">= 0.8" } }, + "node_modules/send/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", @@ -11519,6 +11867,23 @@ "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==", "license": "MIT" }, + "node_modules/strtok3": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz", + "integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "peek-readable": "^4.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/superagent": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/superagent/-/superagent-9.0.2.tgz", @@ -11926,6 +12291,23 @@ "node": ">=0.6" } }, + "node_modules/token-types": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz", + "integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -12203,6 +12585,15 @@ "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==", "license": "MIT" }, + "node_modules/undici": { + "version": "6.21.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.1.tgz", + "integrity": "sha512-q/1rj5D0/zayJB2FraXdaWxbhWiNKDvu8naDT2dl1yTlvJp4BLtOcp2a5BvgGNQpYYJzau7tf1WgKv3b+7mqpQ==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", @@ -12500,6 +12891,39 @@ "node": ">=4.0" } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", diff --git a/package.json b/package.json index 49524a9..a7fae25 100644 --- a/package.json +++ b/package.json @@ -30,16 +30,21 @@ "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.0.0", "@nestjs/platform-socket.io": "^10.4.15", - "@nestjs/swagger": "^8.1.0", + "@nestjs/swagger": "^8.1.1", "@prisma/client": "^6.1.0", "axios": "^1.7.9", "bcrypt": "^5.1.1", + "cheerio": "^1.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "cookie-parser": "^1.4.7", + "date-fns": "^4.1.0", + "dayjs": "^1.11.13", "dotenv": "^16.4.7", + "file-type": "^16.5.4", "ioredis": "^5.4.2", "jsonwebtoken": "^9.0.2", + "mime": "^4.0.6", "passport": "^0.7.0", "passport-github": "^1.1.0", "passport-github2": "^0.1.12", @@ -59,6 +64,7 @@ "@types/cookie-parser": "^1.4.8", "@types/express": "^5.0.0", "@types/jest": "^29.5.2", + "@types/multer": "^1.4.12", "@types/node": "^20.3.1", "@types/passport-github2": "^1.2.9", "@types/passport-google-oauth20": "^2.0.16", diff --git a/prisma/migrations/20250110074825_chat_logic_fixed/migration.sql b/prisma/migrations/20250110074825_chat_logic_fixed/migration.sql new file mode 100644 index 0000000..33b5ff3 --- /dev/null +++ b/prisma/migrations/20250110074825_chat_logic_fixed/migration.sql @@ -0,0 +1,37 @@ +/* + Warnings: + + - The primary key for the `Channel` table will be changed. If it partially fails, the table could be left without primary key constraint. + - You are about to alter the column `id` on the `Channel` table. The data in that column could be lost. The data in that column will be cast from `VarChar(191)` to `Int`. + - You are about to alter the column `channel_id` on the `Channel_users` table. The data in that column could be lost. The data in that column will be cast from `VarChar(191)` to `Int`. + - You are about to alter the column `channel_id` on the `Message` table. The data in that column could be lost. The data in that column will be cast from `VarChar(191)` to `Int`. + +*/ +-- DropForeignKey +ALTER TABLE `Channel_users` DROP FOREIGN KEY `Channel_users_channel_id_fkey`; + +-- DropForeignKey +ALTER TABLE `Message` DROP FOREIGN KEY `Message_channel_id_fkey`; + +-- DropIndex +DROP INDEX `Channel_users_channel_id_fkey` ON `Channel_users`; + +-- DropIndex +DROP INDEX `Message_channel_id_fkey` ON `Message`; + +-- AlterTable +ALTER TABLE `Channel` DROP PRIMARY KEY, + MODIFY `id` INTEGER NOT NULL AUTO_INCREMENT, + ADD PRIMARY KEY (`id`); + +-- AlterTable +ALTER TABLE `Channel_users` MODIFY `channel_id` INTEGER NOT NULL; + +-- AlterTable +ALTER TABLE `Message` MODIFY `channel_id` INTEGER NOT NULL; + +-- AddForeignKey +ALTER TABLE `Channel_users` ADD CONSTRAINT `Channel_users_channel_id_fkey` FOREIGN KEY (`channel_id`) REFERENCES `Channel`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `Message` ADD CONSTRAINT `Message_channel_id_fkey` FOREIGN KEY (`channel_id`) REFERENCES `Channel`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20250114034707_online_users_table/migration.sql b/prisma/migrations/20250114034707_online_users_table/migration.sql new file mode 100644 index 0000000..6aab0ba --- /dev/null +++ b/prisma/migrations/20250114034707_online_users_table/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE `User` ADD COLUMN `password` VARCHAR(191) NULL; + +-- CreateTable +CREATE TABLE `online_users` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `client_id` VARCHAR(191) NOT NULL, + `user_id` INTEGER NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ee7270a..ff183b7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8,42 +8,46 @@ datasource db { } model User { - id Int @id @default(autoincrement()) - email String @unique - name String - nickname String - password String? - auth_provider String - profile_url String? - role_id Int - introduce String? - status_id Int - apply_count Int? @default(0) - post_count Int? @default(0) - push_alert Boolean @default(false) - following_alert Boolean @default(false) - project_alert Boolean @default(false) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - ArtistData ArtistData? - Channel_users Channel_users[] - FeedComments FeedComment[] - FeedLikes FeedLike[] - FeedPosts FeedPost[] - Followed Follows[] @relation("FollowedUsers") - Follows Follows[] @relation("UserFollows") - Message Message[] - Message_status Message_status[] - ProgrammerData ProgrammerData? - ProjectPosts Project? - ProjectPost ProjectPost[] - ProjectSaves ProjectSave[] - Resume Resume[] - role Role @relation(fields: [role_id], references: [id]) - status Status @relation(fields: [status_id], references: [id]) - UserApplyProject UserApplyProject[] - UserLinks UserLink[] - UserSkills UserSkill[] + id Int @id @default(autoincrement()) + email String @unique + name String + nickname String @unique + auth_provider String + profile_url String? + role_id Int + introduce String? + status_id Int + apply_count Int? @default(0) + post_count Int? @default(0) + push_alert Boolean @default(false) + following_alert Boolean @default(false) + project_alert Boolean @default(false) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + password String? + job_detail String? + ArtistData ArtistData[] + Channel_users Channel_users[] + FeedComments FeedComment[] + FeedCommentLikes FeedCommentLikes[] + FeedLikes FeedLike[] + FeedPosts FeedPost[] + Followed Follows[] @relation("FollowedUsers") + Follows Follows[] @relation("UserFollows") + Last_message_status Last_message_status[] + Message Message[] + MyPageProject MyPageProject[] + UserLinks MyPageUserLink[] + sentNotifications Notification[] @relation("SenderNotifications") + notifications Notification[] @relation("UserNotifications") + ProgrammerData ProgrammerData? + ProjectPost ProjectPost[] + ProjectSaves ProjectSave[] + Resume Resume[] + role Role @relation(fields: [role_id], references: [id]) + status Status @relation(fields: [status_id], references: [id]) + UserApplyProject UserApplyProject[] + UserSkills UserSkill[] @@index([role_id], map: "User_role_id_fkey") @@index([status_id], map: "User_status_id_fkey") @@ -55,52 +59,52 @@ model Role { Users User[] } -model Project { - id Int @id @default(autoincrement()) - user_id Int @unique - title String - description String - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - user User @relation(fields: [user_id], references: [id]) - ProjectLinks ProjectLink[] +model MyPageProject { + id Int @id @default(autoincrement()) + user_id Int + title String + description String + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + projectProfileUrl String? + user User @relation(fields: [user_id], references: [id]) + ProjectLinks MyPageProjectLink[] + + @@index([user_id]) } -model ProjectLink { - id Int @id @default(autoincrement()) +model MyPageProjectLink { + id Int @id @default(autoincrement()) project_id Int type_id Int url String - project Project @relation(fields: [project_id], references: [id]) - type LinkType @relation(fields: [type_id], references: [id]) + project MyPageProject @relation(fields: [project_id], references: [id]) + type LinkType @relation(fields: [type_id], references: [id]) @@index([project_id], map: "ProjectLink_project_id_fkey") @@index([type_id], map: "ProjectLink_type_id_fkey") } model LinkType { - id Int @id @default(autoincrement()) - name String @unique - Links ProjectLink[] + id Int @id @default(autoincrement()) + name String @unique + Links MyPageProjectLink[] } model ProgrammerData { - id Int @id @default(autoincrement()) - user_id Int @unique - github_username String - github_url String - commit_count Int - contribution_data String - user User @relation(fields: [user_id], references: [id]) + id Int @id @default(autoincrement()) + user_id Int @unique + github_username String + user User @relation(fields: [user_id], references: [id]) } model ArtistData { - id Int @id @default(autoincrement()) - user_id Int @unique - soundcloud_url String - portfolio_url String - music_data String - user User @relation(fields: [user_id], references: [id]) + id Int @id @default(autoincrement()) + user_id Int + music_url String + user User @relation(fields: [user_id], references: [id]) + + @@index([user_id], map: "ArtistData_user_id_fkey") } model Status { @@ -110,11 +114,12 @@ model Status { } model Resume { - id Int @id @default(autoincrement()) - user_id Int - title String - introduce String - user User @relation(fields: [user_id], references: [id]) + id Int @id @default(autoincrement()) + user_id Int + title String + detail String @db.Text + portfolio_url String? + user User @relation(fields: [user_id], references: [id]) @@index([user_id], map: "Resume_user_id_fkey") } @@ -136,12 +141,11 @@ model UserSkill { @@index([skill_id], map: "UserSkill_skill_id_fkey") } -model UserLink { - id Int @id @default(autoincrement()) - user_id Int - platform String - link String - user User @relation(fields: [user_id], references: [id]) +model MyPageUserLink { + id Int @id @default(autoincrement()) + user_id Int + link String + user User @relation(fields: [user_id], references: [id]) @@index([user_id], map: "UserLink_user_id_fkey") } @@ -162,13 +166,13 @@ model FeedPost { id Int @id @default(autoincrement()) user_id Int title String - content String - thumbnail_url String + content String @db.Text + thumbnail_url String? created_at DateTime @default(now()) updated_at DateTime @updatedAt - view Int - comment_count Int - like_count Int + view Int @default(0) + comment_count Int @default(0) + like_count Int @default(0) Comments FeedComment[] Likes FeedLike[] user User @relation(fields: [user_id], references: [id]) @@ -195,15 +199,16 @@ model FeedPostTag { } model FeedComment { - id Int @id @default(autoincrement()) - user_id Int - post_id Int - content String - image_url String? - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - post FeedPost @relation(fields: [post_id], references: [id]) - user User @relation(fields: [user_id], references: [id]) + id Int @id @default(autoincrement()) + user_id Int + post_id Int + content String + image_url String? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + post FeedPost @relation(fields: [post_id], references: [id]) + user User @relation(fields: [user_id], references: [id]) + FeedCommentLikes FeedCommentLikes[] @@index([post_id], map: "FeedComment_post_id_fkey") @@index([user_id], map: "FeedComment_user_id_fkey") @@ -224,40 +229,34 @@ model ProjectPost { id Int @id @default(autoincrement()) user_id Int title String - content String - thumbnail_url String - role Int - unit String + content String @db.Text + thumbnail_url String? + role String start_date DateTime - end_date DateTime - work_type_id Int - recruiting Boolean - applicant_count Int - view Int - saved_count Int + recruiting Boolean @default(true) + applicant_count Int @default(0) + view Int @default(0) + saved_count Int @default(0) + created_at DateTime @default(now()) + duration String + hub_type String + work_type String Details ProjectDetailRole[] user User @relation(fields: [user_id], references: [id]) - work_type WorkType @relation(fields: [work_type_id], references: [id]) Tags ProjectPostTag[] Saves ProjectSave[] Applications UserApplyProject[] @@index([user_id], map: "ProjectPost_user_id_fkey") - @@index([work_type_id], map: "ProjectPost_work_type_id_fkey") -} - -model WorkType { - id Int @id @default(autoincrement()) - name String - ProjectPosts ProjectPost[] } model ProjectSave { - id Int @id @default(autoincrement()) - user_id Int - post_id Int - post ProjectPost @relation(fields: [post_id], references: [id]) - user User @relation(fields: [user_id], references: [id]) + id Int @id @default(autoincrement()) + user_id Int + post_id Int + created_at DateTime @default(now()) + post ProjectPost @relation(fields: [post_id], references: [id]) + user User @relation(fields: [user_id], references: [id]) @@index([post_id], map: "ProjectSave_post_id_fkey") @@index([user_id], map: "ProjectSave_user_id_fkey") @@ -277,45 +276,52 @@ model ProjectDetailRole { model DetailRole { id Int @id @default(autoincrement()) role_id Int - name String + name String @unique Details ProjectDetailRole[] } model ProjectTag { id Int @id @default(autoincrement()) - name String + name String @unique Tags ProjectPostTag[] } model ProjectPostTag { - id Int @id @default(autoincrement()) - post_id Int - tag_id Int - post ProjectPost @relation(fields: [post_id], references: [id]) - tag ProjectTag @relation(fields: [tag_id], references: [id]) + id Int @id @default(autoincrement()) + post_id Int + tag_id Int + created_at DateTime @default(now()) + post ProjectPost @relation(fields: [post_id], references: [id]) + tag ProjectTag @relation(fields: [tag_id], references: [id]) @@unique([post_id, tag_id]) @@index([tag_id], map: "ProjectPostTag_tag_id_fkey") } model UserApplyProject { - id Int @id @default(autoincrement()) - user_id Int - post_id Int - post ProjectPost @relation(fields: [post_id], references: [id]) - user User @relation(fields: [user_id], references: [id]) + id Int @id @default(autoincrement()) + user_id Int + post_id Int + status String @default("Pending") + created_at DateTime @default(now()) + post ProjectPost @relation(fields: [post_id], references: [id]) + user User @relation(fields: [user_id], references: [id]) + @@unique([user_id, post_id]) @@index([post_id], map: "UserApplyProject_post_id_fkey") @@index([user_id], map: "UserApplyProject_user_id_fkey") } model Channel { - id Int @id @default(autoincrement()) - name String @default("default_channel_name") - active Boolean @default(true) - created_at DateTime @default(now()) - Channel_users Channel_users[] - Message Message[] + id Int @id @default(autoincrement()) + title String @default("default_channel_title") + type String + thumbnail_url String? + active Boolean @default(true) + created_at DateTime @default(now()) + Channel_users Channel_users[] + Last_message_status Last_message_status[] + Message Message[] } model Channel_users { @@ -331,28 +337,63 @@ model Channel_users { } model Message { - id Int @id @default(autoincrement()) - user_id Int - created_at DateTime @default(now()) - channel_id Int - content String - type String - channel Channel @relation(fields: [channel_id], references: [id]) - user User @relation(fields: [user_id], references: [id]) - message_status Message_status[] + id Int @id @default(autoincrement()) + user_id Int + created_at DateTime @default(now()) + channel_id Int + content String + type String + read_count Int @default(0) + Last_message_status Last_message_status[] + channel Channel @relation(fields: [channel_id], references: [id]) + user User @relation(fields: [user_id], references: [id]) @@index([channel_id], map: "Message_channel_id_fkey") @@index([user_id], map: "Message_user_id_fkey") } -model Message_status { - id Int @id @default(autoincrement()) - message_id Int - user_id Int - is_read Boolean @default(false) - message Message @relation(fields: [message_id], references: [id]) - user User @relation(fields: [user_id], references: [id]) +model FeedCommentLikes { + id Int @id @default(autoincrement()) + user_id Int + comment_id Int + FeedComment FeedComment @relation(fields: [comment_id], references: [id]) + User User @relation(fields: [user_id], references: [id]) + + @@index([comment_id], map: "FeedCommentLikes_comment_id_fkey") + @@index([user_id], map: "FeedCommentLikes_user_id_fkey") +} + +model online_users { + id Int @id @default(autoincrement()) + client_id String + user_id Int +} + +model Notification { + id Int @id @default(autoincrement()) + userId Int + type String + message String + isRead Boolean @default(false) + createdAt DateTime @default(now()) + senderId Int + sender User @relation("SenderNotifications", fields: [senderId], references: [id]) + user User @relation("UserNotifications", fields: [userId], references: [id]) - @@index([message_id], map: "Message_status_message_id_fkey") - @@index([user_id], map: "Message_status_user_id_fkey") + @@index([userId], map: "Notification_userId_fkey") + @@index([senderId], map: "Notification_senderId_fkey") +} + +model Last_message_status { + id Int @id @default(autoincrement()) + channel_id Int + user_id Int + last_message_id Int + Channel Channel @relation(fields: [channel_id], references: [id]) + Message Message @relation(fields: [last_message_id], references: [id]) + User User @relation(fields: [user_id], references: [id]) + + @@index([channel_id], map: "Last_message_status_channel_id_fkey") + @@index([last_message_id], map: "Last_message_status_last_message_id_fkey") + @@index([user_id], map: "Last_message_status_user_id_fkey") } diff --git a/src/app.module.ts b/src/app.module.ts index 29b5416..7d97953 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -4,6 +4,11 @@ import { AuthModule } from '@modules/auth/auth.module'; import { UserModule } from '@modules/user/user.module'; import { ChatGateway } from './chat/chat.gateway'; import { ChatModule } from './chat/chat.module'; +import { FeedModule } from './feed/feed.module'; +import { ProjectModule } from '@modules/project/project.module'; +import { SearchModule } from './search/search.module'; +import { FollowModule } from '@modules/follow/follow.module'; +import { NotificationModule } from '@modules/notification/notification.module'; @Module({ imports: [ ConfigModule.forRoot({ @@ -12,6 +17,11 @@ import { ChatModule } from './chat/chat.module'; AuthModule, // Auth 모듈 추가 UserModule, ChatModule, + FeedModule, + ProjectModule, + SearchModule, + FollowModule, + NotificationModule, ], providers: [ChatGateway], }) diff --git a/src/chat/chat.controller.spec.ts b/src/chat/chat.controller.spec.ts new file mode 100644 index 0000000..571463d --- /dev/null +++ b/src/chat/chat.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ChatController } from './chat.controller'; + +describe('ChatController', () => { + let controller: ChatController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ChatController], + }).compile(); + + controller = module.get(ChatController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/chat/chat.controller.ts b/src/chat/chat.controller.ts new file mode 100644 index 0000000..528f201 --- /dev/null +++ b/src/chat/chat.controller.ts @@ -0,0 +1,54 @@ +import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common'; +import { ChatService } from './chat.service'; +import { JwtAuthGuard } from '@src/modules/auth/guards/jwt-auth.guard'; +import { GetMessageDto } from './dto/getMessage.dto'; +import { SearchMessageDto } from './dto/serchMessage.dto'; + +@Controller('chat') +export class ChatController { + constructor(private readonly chatService: ChatService) {} + + // 유저가 참여한 채널 전체 조회 + @Get('channels') + @UseGuards(JwtAuthGuard) + async getAllChannels(@Req() req: any) { + const userId = +req.user.user_id; + return this.chatService.getAllChannels(userId); + } + + // 채널 메세지 조회 + @Get('channels/:id/messages') + @UseGuards(JwtAuthGuard) + async getChannelsMessages( + @Req() req: any, + @Param('id') channelId: number, + @Query() getMessageDto: GetMessageDto + ) { + return await this.chatService.getMessages( + req.user.user_id, + channelId, + getMessageDto + ); + } + + @Get('channels/:id') + @UseGuards(JwtAuthGuard) + async getChannel(@Req() req: any, @Param('id') channelId: number) { + return await this.chatService.getChannel(req.user.user_id, channelId); + } + + // 채널 메세지 검색 + @Get('channels/:id/messages/search') + @UseGuards(JwtAuthGuard) + async searchChannelMessages( + @Req() req: any, + @Param('id') channelId: number, + @Query() searchMessageDto: SearchMessageDto + ) { + return await this.chatService.searchMessage( + req.user.user_id, + channelId, + searchMessageDto + ); + } +} diff --git a/src/chat/chat.gateway.ts b/src/chat/chat.gateway.ts index 28962cb..3e6dcb2 100644 --- a/src/chat/chat.gateway.ts +++ b/src/chat/chat.gateway.ts @@ -4,45 +4,250 @@ import { MessageBody, WebSocketServer, ConnectedSocket, + OnGatewayConnection, + OnGatewayDisconnect, } from '@nestjs/websockets'; import { ChatService } from './chat.service'; -import { JwtService } from '@nestjs/jwt'; -import { Server, Socket } from 'socket.io'; +import { Namespace, Socket } from 'socket.io'; +import { NotificationsService } from '@src/modules/notification/notification.service'; @WebSocketGateway({ namespace: 'chat', cors: { origin: '*' } }) -export class ChatGateway { +export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { constructor( private readonly chatService: ChatService, - private readonly jwtService: JwtService + private readonly notificationService: NotificationsService ) {} - @WebSocketServer() server: Server; - // 채팅방 참여 + + @WebSocketServer() server: Namespace; + + // 유저 소켓 접속 + async handleConnection(client: Socket) { + const userId = +client.handshake.query.userId; + client.data.userId = userId; // userId 넘버로 저장 + + // 유저 온라인 -> DB에 저장 + await this.chatService.addUserOnline(userId, client.id); + console.log(`User ${userId} connected with socket ID ${client.id}`); + + // 유저가 참여한 전체 채널 조회 + const channels = await this.chatService.getAllChannels(userId); + + // channel(유저가 참여한 전체 채널) 배열 형태로 전송 + client.emit('fetchChannels', channels); + } + + // 유저 소켓 접속 해제 + async handleDisconnect(client: Socket) { + // 유저 아이디 소켓 객체(client)에서 가져옴 + const userId = client.data.userId; + + // 온라인 여부 DB에서 삭제 + await this.chatService.deleteUserOnline(userId); + + console.log(`User ${userId} disconnected.`); + } + + // 1대1 새 채팅방 생성 (userId1(클라이언트/본인), userId2(상대방)) + @SubscribeMessage('createChannel') + async handleCreateChannel( + @MessageBody() + data: { userId1: number; userId2: number }, + @ConnectedSocket() client: Socket + ) { + const { userId1, userId2 } = data; + client.data.userId = userId1; + + // 채널 id 조회 + const channelId = await this.chatService.getChannelId(userId1, userId2); + + // 채널 객체 조회 + const channelData = await this.chatService.getChannel(userId1, channelId); + const channel = channelData.channel; + + // 채널에 유저 참여, 채널리스트에 해당 채널 추가 + client.join(channelId.toString()); + client.emit('channelAdded', channel); + + console.log(`client ${client.data.userId} ${channelId}번 채팅방 입장`); + + // B 유저 온라인 여부 확인 + const targetSocket = await this.chatService.getSocketIds([userId2]); + + // 온라인 일때 + if (targetSocket.length) { + // 유저2의 소켓 가져오기 + const target = targetSocket[0]; + const sockets = await this.server.fetchSockets(); + const userSocket = sockets.find( + socket => socket.id === target.toString() + ); + + // 유저2의 채널 리스트에 해당 채널 추가 + userSocket.emit('channelAdded', channel); + console.log(`channel ${channelId} added in ${userId2} channel list`); + } else { + // 오프라인 일때 + console.log(`User ${userId2} is not connected.`); + } + + // 알람 기능 + const sender = await this.chatService.getSenderProfile(userId1); + + const message = `${sender.nickname}님과의 개인 채팅방이 생성되었습니다.`; + + // 알람 DB에 저장 + const createdNotification = + await this.notificationService.createNotification( + userId2, + userId1, + 'privateChat', + message + ); + + // 전송할 알림 데이터 객체 + const notificationData = { + notificationId: createdNotification.notificationId, // 포함된 notificationId + type: 'privateChat', + message, + senderNickname: sender.nickname, + senderProfileUrl: sender.profileUrl, + }; + + // SSE를 통해 실시간 알림 전송 + this.notificationService.sendRealTimeNotification( + userId2, + notificationData + ); + + // 클라이언트에 채널id 전달 + client.emit('channelCreated', channel); + } + + // 그룹 채팅방 생성 + @SubscribeMessage('createGroup') + async handleCreateGroup( + @MessageBody() + data: { userIds: number[]; title: string; thumnailUrl: string }, + @ConnectedSocket() client: Socket + ) { + const { userIds, title, thumnailUrl } = data; + // userIds[0] => 클라이언트(그룹 채팅 마스터) + const userId = userIds[0]; + + // 채널 id 조회 + const channelId = await this.chatService.getGroupChannelId( + userIds, + title, + thumnailUrl + ); + + // 채널 객체 조회 + const channelData = await this.chatService.getChannel(userId, channelId); + const channel = channelData.channel; + + // 채널에 마스터 유저 참여 & 채널리스트에 추가 + client.join(channelId.toString()); + client.emit('channelAdded', channel); + console.log(`client ${client.data.userId} ${channelId}번 채팅방 입장`); + + // 나머지 유저 온라인 여부 확인 + const groupMemberIds = userIds.filter(id => id !== userId); + const targetSockets = await this.chatService.getSocketIds(groupMemberIds); + + // 온라인인 유저가 있을 때 + if (targetSockets.length) { + // 접속중인 모든 소켓 확인 + const sockets = await this.server.fetchSockets(); + + // 채널 멤버들의 소켓만 조회하게 필터링 + const userSockets = sockets.filter(socket => + targetSockets.includes(socket.id) + ); + + // 각 멤버들의 채널 리스트에 해당 채널 추가 + userSockets.forEach(async socket => { + socket.emit('channelAdded', channel); + }); + } else { + // 오프라인 일때 + console.log('모든 유저가 오프라인 상태입니다.'); + } + + const sender = await this.chatService.getSenderProfile(userId); + const message = `${sender.nickname}님이 단체 채팅방을 생성했습니다.`; + + groupMemberIds.forEach(async memberId => { + const createdNotification = + await this.notificationService.createNotification( + memberId, + userId, + 'groupChat', + message + ); + + // 전송할 알림 데이터 객체 + const notificationData = { + notificationId: createdNotification.notificationId, // 포함된 notificationId + type: 'groupChat', + message, + senderNickname: sender.nickname, + senderProfileUrl: sender.profileUrl, + }; + + // SSE를 통해 실시간 알림 전송 + this.notificationService.sendRealTimeNotification( + memberId, + notificationData + ); + }); + + // 클라이언트에 채널id 전달 + client.emit('groupCreated', channel); + } + + // 채널 참여 @SubscribeMessage('joinChannel') async handleJoinChannel( - @MessageBody() data: { channelId: string; userId: string }, + @MessageBody() data: { userId: number; channelId: number }, @ConnectedSocket() client: Socket ) { - const { channelId, userId } = data; + const { userId, channelId } = data; - // userId JWT 토큰 값이라 디코딩 해야함 - const user = this.jwtService.decode(userId); + // 채널 참여 + client.join(channelId.toString()); + console.log(`유저 ${userId} 채널 ${channelId} 참여`); - // 존재하는 채팅방인지 확인 - const existData = await this.chatService.channelExist(channelId, user.id); - if (existData) { - // 채팅방 참여 - client.join(channelId); - return existData; - } + // 라스트 메세지 id 확인 + const lastMessage = await this.chatService.getLastMessageId( + userId, + channelId + ); + + // 라스트 메세지보다 id가 큰 값(최신 메세지) 리드 카운트 증가 + const lastMessageId = lastMessage ? lastMessage.last_message_id : 0; + await this.chatService.updateReadCount(lastMessageId, channelId); - // 채팅방 생성 - await this.chatService.createChannel(channelId); + // 채널 라스트 메세지 조회 + const channelLastMessage = + await this.chatService.getChannelLastMessage(channelId); - // 채팅방 참여 - client.join(channelId); + const channelLastMessageId = channelLastMessage?.id; - // 채팅방 멤버 저장 - await this.chatService.joinChannel(channelId, user.id); + if (channelLastMessageId) { + // 채널 입장 시 채널의 마지막 메세지 last message로 저장 + await this.chatService.setLastMessageId( + userId, + channelId, + channelLastMessageId + ); + } + // 채널 객체 + const channelData = await this.chatService.getChannel(userId, channelId); + const { channel } = channelData; + + // 클라이언트에 채널 객체 전달 + client.emit('channelJoined', channel); + this.server.to(channelId.toString()).emit('broadcastChannelJoined'); } // 메세지 송수신 @@ -52,27 +257,118 @@ export class ChatGateway { data: { type: string; content: string; - user: any; - channelId: string; + userId: number; + channelId: number; } ) { - // user 디코딩 - const user = this.jwtService.decode(data.user); - - // 메세지 데이터 저장 - await this.chatService.createMessage( - data.type, - data.channelId, - user.id, - data.content - ); + const { userId } = data; + let messageData; + + // 전달 타입에 따라 메세지 데이터 저장 + if (data.type == 'image') { + const result = await this.chatService.handleChatFiles( + userId, + data.content + ); + + messageData = await this.chatService.createMessage( + data.type, + data.channelId, + userId, + result.imageUrl + ); + } else { + messageData = await this.chatService.createMessage( + data.type, + data.channelId, + userId, + data.content + ); + } + + const messageId = messageData.id; // 유저 정보 추가 - const userData = await this.chatService.getSenderProfile(user.id); - data.user = userData; - const createdAt = new Date(); - const sendData = { ...data, createdAt }; + const user = await this.chatService.getSenderProfile(userId); + + const date = new Date(); - this.server.to(data.channelId).emit('message', sendData); + // 전달 데이터 양식 + const sendData = { + type: data.type, + content: messageData.content, + channelId: data.channelId, + messageId, + user, + date, + readCount: messageData.read_count, + }; + console.log(sendData); + + // 오프라인 유저들에게 알람 + const offlineUsers = await this.chatService.getChannelOfflineUsers( + data.channelId + ); + + if (offlineUsers.length) { + const message = '새로운 메세지가 있습니다.'; + + offlineUsers.forEach(async id => { + const createdNotification = + await this.notificationService.createNotification( + id, + userId, + 'groupChat', + message + ); + + const notificationData = { + notificationId: createdNotification.notificationId, // 포함된 notificationId + type: 'groupChat', + message, + senderNickname: user.nickname, + senderProfileUrl: user.profileUrl, + }; + + this.notificationService.sendRealTimeNotification(id, notificationData); + }); + } + + this.server.to(data.channelId.toString()).emit('message', sendData); + } + + @SubscribeMessage('exitChannel') + async handleLeaveChannel( + @MessageBody() + data: { userId: number; channelId: number }, + @ConnectedSocket() client: Socket + ) { + const { userId, channelId } = data; + + // 채널 나가기 + client.leave(channelId.toString()); + // 채널 나간 후 클라이언트에게 채널 아이디 전달 + client.emit('channelExited', channelId); + + // DB에서 유저 삭제 + 채널 탈퇴 메세지 DB 저장 후 반환 + const leaveMessage = await this.chatService.deleteUser(userId, channelId); + + this.server.to(channelId.toString()).emit('message', leaveMessage); + } + + // 메세지 실시간 읽음처리 + @SubscribeMessage('readMessage') + async handleReadCount( + @MessageBody() + data: { + userId: number; + messageId: number; + channelId: number; + } + ) { + const { userId, channelId, messageId } = data; + await this.chatService.increaseReadCount(messageId); + await this.chatService.setLastMessageId(userId, channelId, messageId); + this.server.to(data.channelId.toString()).emit('readCounted', messageId); } } diff --git a/src/chat/chat.module.ts b/src/chat/chat.module.ts index 261283e..10ee31e 100644 --- a/src/chat/chat.module.ts +++ b/src/chat/chat.module.ts @@ -1,11 +1,15 @@ import { Module } from '@nestjs/common'; import { ChatService } from './chat.service'; import { PrismaModule } from '@src/prisma/prisma.module'; -import { AuthModule } from '@modules/auth/auth.module'; // AuthModule 추가 +import { JwtModule } from '@nestjs/jwt'; +import { ChatController } from './chat.controller'; +import { S3Module } from '@src/s3/s3.module'; +import { NotificationModule } from '@src/modules/notification/notification.module'; @Module({ - imports: [PrismaModule, AuthModule], + imports: [PrismaModule, JwtModule, S3Module, NotificationModule], providers: [ChatService], exports: [ChatService], + controllers: [ChatController], }) export class ChatModule {} diff --git a/src/chat/chat.service.ts b/src/chat/chat.service.ts index 41476a9..54cf5ca 100644 --- a/src/chat/chat.service.ts +++ b/src/chat/chat.service.ts @@ -1,67 +1,163 @@ -import { Injectable } from '@nestjs/common'; +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { PrismaService } from '@src/prisma/prisma.service'; +import { GetMessageDto } from './dto/getMessage.dto'; +import { SearchMessageDto } from './dto/serchMessage.dto'; +import { S3Service } from '@src/s3/s3.service'; +import * as fileType from 'file-type'; @Injectable() export class ChatService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly s3: S3Service + ) {} - // 이미 존재하는 채팅방인지 확인 - // 없다면 생성, 있다면 매핑 테이블 확인 - // 매핑 테이블 유저 데이터 있다면 대화 내용 불러오기, 없다면 매핑 테이블에 유저 데이터 생성 - async channelExist(channelId, userId) { - const exist = await this.prisma.channel.count({ - where: { id: channelId }, + // 온라인 유저 DB에 저장 + async addUserOnline(userId: number, clientId: string) { + await this.prisma.online_users.create({ + data: { + user_id: userId, + client_id: clientId, + }, }); + } - // 존재하지 않으면 함수 종료 후 컨트롤러에서 채팅방 생성 로직 재개 - if (!exist) return false; - - // 매핑 테이블 channel_users에 유저 데이터 있는지 확인 - const userExist = await this.prisma.channel_users.findFirst({ + // 오프라인 유저 DB에서 삭제 + async deleteUserOnline(userId: number) { + await this.prisma.online_users.deleteMany({ where: { - channel_id: channelId, user_id: userId, }, }); + } - // 있다면 대화내용 불러오기 - if (userExist) { - const message = await this.prisma.message.findMany({ - where: { - channel_id: channelId, + // 유저 아이디를 통해 유저의 소켓 아이디 가져오기 + async getSocketIds(Ids: number[]) { + const socketIds = await this.prisma.online_users.findMany({ + where: { user_id: { in: Ids } }, + select: { client_id: true }, + }); + + return socketIds.map(id => id.client_id); + } + + // 기존 채널 조회 or 새 채널 생성 + // 채널id 리턴 (개인 채팅방) + async getChannelId(userId1: number, userId2: number) { + // 매핑 테이블에서 파라미터로 전달된 유저 아이디에 해당하는 데이터 찾기 + + const privateChannel = await this.prisma.channel.findMany({ + where: { type: 'private' }, + select: { id: true }, + }); + + const privateChannelIds = []; + + privateChannel.map(res => { + privateChannelIds.push(res.id); + }); + + const result = await this.prisma.channel_users.groupBy({ + by: ['channel_id'], + where: { + channel_id: { + in: privateChannelIds, }, - }); - return message; - } else { - // 없다면 매핑 테이블에 유저 데이터 추가 - await this.prisma.channel_users.create({ - data: { - channel_id: channelId, - user_id: userId, + user_id: { + in: [userId1, userId2], }, - }); - const notice = `${userId}님이 채팅방에 참가했습니다`; - return { notice }; - } - } + }, + _count: { + user_id: true, + }, + }); + + // user_id == 2 -> 두 유저가 모두 참여한 채널 필터링 + const channel = result.filter(data => data._count.user_id == 2)[0]; - // 채팅방 생성 - async createChannel(id) { - await this.prisma.channel.create({ data: { id } }); + // 참여한 채널이 있다면 채널 id 리턴 + if (channel) return channel.channel_id; + + // 없다면 새로운 채널 생성 후 + const newChannel = await this.prisma.channel.create({ + data: { + type: 'private', + }, + }); + + const channelId = newChannel.id; + + // 매핑 테이블에 데이터 저장 + await this.createChannelUsers(channelId, [userId1, userId2]); + + // 채널 id 리턴 + return channelId; } - // 채팅방 멤버 저장 - async joinChannel(channelId, userId) { - await this.prisma.channel_users.create({ + // 기존 채널 조회 or 새 채널 생성 + // 채널id 리턴 (그룹 채팅방) + async getGroupChannelId( + userIds: number[], + title: string, + thumnailUrl: string + ) { + // 기존 채널 조회 + const result = await this.prisma.channel_users.groupBy({ + by: ['channel_id'], + where: { + user_id: { + in: userIds, + }, + }, + _count: { + user_id: true, + }, + }); + + if (result.length) { + const exist = result.filter( + data => data._count.user_id == userIds.length + )[0]; + + if (exist) { + return exist.channel_id; + } + } + + // 새로운 채널 생성 + const channel = await this.prisma.channel.create({ data: { - channel_id: channelId, - user_id: userId, + title, + thumbnail_url: thumnailUrl, + type: 'group', }, }); + const channelId = channel.id; + + // 매핑 테이블에 저장 + await this.createChannelUsers(channelId, userIds); + + return channelId; } + + // channle_users 테이블에 채널-유저 저장 + async createChannelUsers(channelId: number, userIds: number[]) { + const data = userIds.map(userId => ({ + channel_id: channelId, + user_id: userId, + })); + + await this.prisma.channel_users.createMany({ data }); + } + // 메세지 저장 - async createMessage(type, channelId, userId, content) { - await this.prisma.message.create({ + async createMessage( + type: string, + channelId: number, + userId: number, + content: string + ) { + return await this.prisma.message.create({ data: { type, content, @@ -72,8 +168,8 @@ export class ChatService { } // 유저 정보 확인 - async getSenderProfile(userId) { - const data = await this.prisma.user.findUnique({ + async getSenderProfile(userId: number) { + const result = await this.prisma.user.findUnique({ where: { id: userId, }, @@ -84,9 +180,471 @@ export class ChatService { nickname: true, role_id: true, profile_url: true, + auth_provider: true, + }, + }); + + const data = { + userId: result.id, + email: result.email, + name: result.name, + nickname: result.nickname, + profileUrl: result.profile_url, + authProvide: result.auth_provider, + roleId: result.role_id, + }; + + return data; + } + + // 유저가 참여한 채널 전체 조회 + async getAllChannels(id: number) { + const result = await this.prisma.channel_users.findMany({ + where: { user_id: id }, + select: { channel_id: true }, + }); + + const channels = []; + for (const res of result) { + const channel = await this.getChannleObj(res.channel_id); + channels.push(channel); + } + + return channels; + } + + // 채널 개별 조회 + async getChannel(userId: number, channelId: number) { + try { + // 권한 확인 + await this.confirmAuth(userId, channelId); + + const channel = await this.getChannleObj(channelId); + + const message = { + code: 200, + text: '데이터 패칭 성공', + }; + return { channel, message }; + } catch (err) { + return err.message; + } + } + + // 채널 객체 리턴 로직 + async getChannleObj(channelId: number) { + // 채널 데이터 조회 + const result = await this.prisma.channel.findUnique({ + where: { id: channelId }, + include: { + Channel_users: { + select: { + user: { + select: { + id: true, + email: true, + name: true, + nickname: true, + profile_url: true, + auth_provider: true, + role_id: true, + }, + }, + }, + }, + Message: { + take: 1, + orderBy: { id: 'desc' }, + include: { + user: { + select: { + id: true, + email: true, + name: true, + nickname: true, + profile_url: true, + auth_provider: true, + role_id: true, + }, + }, + }, + }, + }, + }); + + // 채널 데이터 양식화 + const channel = { + channelId: result.id, + title: result.title, + type: result.type, + thumnailUrl: result.thumbnail_url, + users: result.Channel_users.map(res => ({ + userId: res.user.id, + email: res.user.email, + name: res.user.name, + nickname: res.user.nickname, + profileUrl: res.user.profile_url, + authProvider: res.user.auth_provider, + roleId: res.user.role_id, + })), + lastMessage: { + type: result.Message[0]?.type, + content: result.Message[0]?.content, + channelId: result.Message[0]?.channel_id, + date: result.Message[0]?.created_at, + userId: result.Message[0]?.user_id, + }, + }; + + return channel; + } + + // 채널 메세지 조회 + async getMessages( + userId: number, + channelId: number, + getMessageDto: GetMessageDto + ) { + try { + const { cursor, limit, direction } = getMessageDto; + // 권한 확인 + await this.confirmAuth(userId, channelId); + + // 메세지 데이터 조회 + const result = await this.prisma.message.findMany({ + orderBy: { + // 커서값이 없다면(초기요청) direction 상관없이 desc 정렬 + id: cursor ? (direction == 'forward' ? 'asc' : 'desc') : 'desc', + }, + where: cursor + ? { + // forward(스크롤 다운)일 시 cursor보다 id 높은 값(최신) + // backward(스크롤 업)일 시 cursor보다 id 낮은 값(오래된) + channel_id: channelId, + id: direction === 'forward' ? { gt: cursor } : { lt: cursor }, + } + : { channel_id: channelId }, // 커서가 없으면 id 조건 없이 가져오기 + + include: { + user: { + select: { + id: true, + email: true, + name: true, + nickname: true, + role: true, + profile_url: true, + auth_provider: true, + }, + }, + }, + + take: limit, + }); + + // 메세지 데이터 양식화 + const data = await this.getMessageObj(result); + + // 메세지 데이터, 메세지 id순 오름차순 정렬 + const messages = + !cursor || direction == 'backward' ? data.reverse() : data; + + // 커서 + const cursors = + direction == 'backward' + ? { prev: data[0] ? data[0].messageId : null } + : { + next: data[data.length - 1] + ? data[data.length - 1].messageId + : null, + }; + + // 응답 메세지 + const message = { + code: 200, + text: '데이터 패칭 성공', + }; + + // 응답데이터 {메세지데이터, 커서, 응답메세지} + return { messages, cursors, message }; + } catch (err) { + return err.message; + } + } + + // 메세지 검색 + async searchMessage( + userId: number, + channelId: number, + searchMessageDto: SearchMessageDto + ) { + try { + const { limit, keyword } = searchMessageDto; + let { cursor, direction } = searchMessageDto; + + // 권한 확인 + await this.confirmAuth(userId, channelId); + + if (!cursor) { + const res = await this.prisma.message.findFirst({ + orderBy: { id: 'desc' }, + where: { channel_id: channelId }, + select: { id: true }, + }); + direction = 'backward'; + cursor = res.id; + } + + // 키워드에 해당하는 메세지id 검색 + const keywordMessage = await this.prisma.message.findFirst({ + orderBy: { id: direction == 'forward' ? 'asc' : 'desc' }, + where: { + channel_id: channelId, + // forward(스크롤 다운)일 시 cursor보다 id 높은 값(최신) + // backward(스크롤 업)일 시 cursor보다 id 낮은 값(오래된) + id: direction == 'forward' ? { gt: cursor } : { lt: cursor }, + content: { contains: keyword }, + }, + select: { id: true }, + }); + + if (!keywordMessage) { + const message = { code: 404, text: '메세지를 찾을 수 없습니다' }; + return { message }; + } + + // 키워드 메세지 커서 설정 + const search = keywordMessage.id; + + // 커서 기준 이전/후 메세지 15개 아이디 조회 + const [forward, backward] = await this.prisma.$transaction([ + this.prisma.message.findMany({ + orderBy: { id: 'desc' }, + where: { channel_id: channelId, id: { lt: search } }, + select: { id: true }, + take: limit, + }), + this.prisma.message.findMany({ + orderBy: { id: 'asc' }, + where: { channel_id: channelId, id: { gt: search } }, + select: { id: true }, + take: limit, + }), + ]); + + const forwardIds = forward.reverse().map(pre => pre.id); + const backwordIds = backward.map(sub => sub.id); + const ids = [...forwardIds, search, ...backwordIds]; + + const messages = await this.getMessageById(ids); + // 무한 스크롤용 커서 데이터 + const cursors = { + // backward 무한스크롤 요청 커서 + prev: forwardIds.length ? forwardIds[0] : null, + // forward 무한스크롤 요청 커서 + next: backwordIds.length ? backwordIds[backwordIds.length - 1] : null, + // 검색 메세지 아이디 커서 + search, + }; + + const message = { + code: 200, + message: '데이터 패칭 성공', + }; + + return { messages, cursors, message }; + } catch (err) { + return err; + } + } + + // 메세지 아이디로 메세지 조회 + async getMessageById(ids) { + const result = await this.prisma.message.findMany({ + where: { id: { in: ids } }, + include: { + user: { + select: { + id: true, + email: true, + name: true, + nickname: true, + role: true, + profile_url: true, + auth_provider: true, + }, + }, + }, + }); + + return this.getMessageObj(result); + } + + // 메세지 데이터 양식화 + async getMessageObj(messages) { + const data = messages.map(msg => ({ + messageId: msg.id, + type: msg.type, + content: msg.content, + channelId: msg.channel_id, + date: msg.created_at, + readCount: msg.read_count, + user: { + userId: msg.user.id, + email: msg.user.email, + name: msg.user.name, + nickname: msg.user.nickname, + profileUrl: msg.user.profile_url, + authProvider: msg.user.auth_provider, + roleId: msg.user.role.id, + }, + })); + return data; + } + + // 유저 채널에서 삭제 + async deleteUser(userId: number, channelId: number) { + await this.prisma.channel_users.deleteMany({ + where: { + channel_id: channelId, + user_id: userId, + }, + }); + const userData = this.getSenderProfile(userId); + const nickname = (await userData).nickname; + + const data = { + type: 'exit', + content: `${nickname} 님이 방을 나갔습니다`, + channel_id: channelId, + user_id: userId, + }; + + const msg = await this.prisma.message.create({ + data, + }); + + return { + userId, + type: msg.type, + content: msg.content, + channelId: msg.channel_id, + date: msg.created_at, + messageId: msg.id, + }; + } + + // 요청 채널에 대한 유저 권한 확인 + async confirmAuth(userId: number, channelId: number) { + const auth = await this.prisma.channel_users.findMany({ + where: { + user_id: userId, + channel_id: channelId, + }, + }); + + if (!auth.length) { + throw new HttpException('권한이 없습니다.', HttpStatus.UNAUTHORIZED); + } + } + + // 이미지 업로드 + async handleChatFiles(userId: number, file) { + const data = await fileType.fromBuffer(file); + const type = data.ext; + + const imageUrl = await this.s3.uploadImage( + userId, + file, + type, + 'pad_chat/images' + ); + + return { + imageUrl, + message: { code: 200, message: '이미지 업로드가 완료되었습니다.' }, + }; + } + + async increaseReadCount(messageId) { + await this.prisma.message.update({ + where: { id: messageId }, + data: { read_count: { increment: 1 } }, + }); + } + + async setLastMessageId(userId, channelId, lastMessageId) { + const exist = await this.prisma.last_message_status.findFirst({ + where: { user_id: userId, channel_id: channelId }, + }); + + if (exist) { + await this.prisma.last_message_status.updateMany({ + where: { user_id: userId, channel_id: channelId }, + data: { last_message_id: lastMessageId }, + }); + } else { + await this.prisma.last_message_status.create({ + data: { + user_id: userId, + channel_id: channelId, + last_message_id: lastMessageId, + }, + }); + } + } + + // 라스트 메세지 id 조회 + async getLastMessageId(userId, channelId) { + const lastMessageId = await this.prisma.last_message_status.findFirst({ + where: { + user_id: userId, + channel_id: channelId, + }, + select: { last_message_id: true }, + }); + + return lastMessageId; + } + + // 리드 카운트 증가 + async updateReadCount(lastMessageId: number, channelId) { + await this.prisma.message.updateMany({ + where: { + channel_id: channelId, + id: { gt: lastMessageId }, + }, + data: { + read_count: { increment: 1 }, }, }); + } + + async getChannelLastMessage(channelId: number) { + const data = await this.prisma.message.findFirst({ + where: { channel_id: channelId }, + orderBy: { id: 'desc' }, + take: 1, + select: { id: true }, + }); + return data; } - // 메세지 상태 업데이트 + + async getChannelOfflineUsers(channelId: number) { + const userData = await this.prisma.channel_users.findMany({ + where: { channel_id: channelId }, + select: { user_id: true }, + }); + const userIds = userData.map(user => user.user_id); + + const onlineData = await this.prisma.online_users.findMany({ + select: { user_id: true }, + }); + const onlineIds = onlineData.map(user => user.user_id); + + const result = userIds.filter(id => !onlineIds.includes(id)); + + return result; + } } diff --git a/src/chat/dto/getMessage.dto.ts b/src/chat/dto/getMessage.dto.ts new file mode 100644 index 0000000..4da4eb2 --- /dev/null +++ b/src/chat/dto/getMessage.dto.ts @@ -0,0 +1,26 @@ +import { Type } from 'class-transformer'; +import { + IsString, + IsNotEmpty, + IsNumber, + IsIn, + IsOptional, +} from 'class-validator'; + +export class GetMessageDto { + @IsNumber({}, { message: 'limit은 숫자타입으로 주어져야 합니다.' }) + @Type(() => Number) + @IsNotEmpty({ message: 'limit 값을 입력해주세요' }) + limit?: number; + + @IsOptional() + @Type(() => Number) + cursor?: number; + + @IsString({ message: 'direction은 문자타입으로 주어져야 합니다' }) + @IsNotEmpty({ message: 'direction을 입력해주세요' }) + @IsIn(['forward', 'backward'], { + message: 'direction은 forward/backward 중 하나로 주어져야 합니다.', + }) + direction: string; +} diff --git a/src/chat/dto/serchMessage.dto.ts b/src/chat/dto/serchMessage.dto.ts new file mode 100644 index 0000000..2b3b1a1 --- /dev/null +++ b/src/chat/dto/serchMessage.dto.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty } from 'class-validator'; +import { GetMessageDto } from './getMessage.dto'; + +export class SearchMessageDto extends GetMessageDto { + @IsNotEmpty({ message: '검색어를 입력해주세요' }) + keyword: any; +} diff --git a/src/common/dto/response.dto.ts b/src/common/dto/response.dto.ts index ed37552..45c783c 100644 --- a/src/common/dto/response.dto.ts +++ b/src/common/dto/response.dto.ts @@ -1,4 +1,4 @@ -export class ApiResponse { +export class MyApiResponse { message: { code: number; // HTTP 상태 코드 text: string; // 메시지 diff --git a/src/config/redis.config.ts b/src/config/redis.config.ts index 35615b9..32d6853 100644 --- a/src/config/redis.config.ts +++ b/src/config/redis.config.ts @@ -4,5 +4,5 @@ import { Injectable } from '@nestjs/common'; export class RedisConfig { public static readonly host = process.env.REDIS_HOST || 'localhost'; public static readonly port = parseInt(process.env.REDIS_PORT, 10) || 6379; - public static readonly password = process.env.REDIS_PASSWORD || undefined; + public static readonly password = 'Ksok484545!'; } diff --git a/src/feed/docs/feed.docs.ts b/src/feed/docs/feed.docs.ts new file mode 100644 index 0000000..12b09dc --- /dev/null +++ b/src/feed/docs/feed.docs.ts @@ -0,0 +1,567 @@ +import { + ApiOperation, + ApiParam, + ApiResponse, + ApiBody, + ApiConsumes, + ApiQuery, + ApiBearerAuth, +} from '@nestjs/swagger'; + +export const getMainPageDocs = { + ApiOperation: ApiOperation({ + summary: '메인 페이지 피드 조회', + description: '작성된 피드 목록을 조회합니다', + }), + ApiQuery: ApiQuery({ + name: 'latest', + required: false, + example: true, + description: '최신순 정렬 여부 (기본값: 인기순)', + }), + ApiQuery2: ApiQuery({ + name: 'cursor', + required: false, + description: '무한 스크롤 커서', + }), + ApiQuery3: ApiQuery({ + name: 'tags', + required: false, + description: '태그 아이디(태그별 게시글 조회할 때 사용)', + }), + + ApiResponse: ApiResponse({ + status: 200, + description: '메인 페이지 피드 목록 조회 성공', + schema: { + example: { + posts: [ + { + userId: '작성자 아이디', + userName: '작성자 이름', + userNickname: '작성자 닉네임', + userRole: '작성자 role', + userProfileUrl: '작성자 프로필 사진 주소', + postId: '게시글 아이디', + title: '게시글 제목', + thumbnailUrl: '게시글 썸네일 사진 주소', + content: '게시글 내용', + tags: ['게시글 태그1', '게시글 태그2'], + commentCount: '댓글 수', + likeCount: '좋아요 수', + viewCount: '조회 수', + isLiked: '좋아요 여부', + createdAt: '작성 시간', + }, + ], + message: { + code: 200, + message: '전체 피드를 정상적으로 조회했습니다.', + }, + }, + }, + }), +}; + +export const getWeeklyBestDocs = { + ApiOperation: ApiOperation({ + summary: '주간 인기 게시글 조회', + description: + '좋아요 순으로 주간 인기 게시글을 조회합니다 좋아요 수가 같을 시 조회 수로 정렬됩니다', + }), + + ApiResponse: ApiResponse({ + status: 200, + description: '주간 인기 게시글 조회 성공', + schema: { + example: { + contents: [ + { + postId: '게시글 아이디', + title: '게시글 제목', + userId: '유저 아이디', + userName: '유저 이름', + userNickname: '유저 닉네임', + userProfileUrl: '유저 프로필 url', + userRole: '유저 roel', + }, + { + postId: '게시글 아이디', + title: '게시글 제목', + userId: '유저 아이디', + userName: '유저 이름', + userNickname: '유저 닉네임', + userProfileUrl: '유저 프로필 url', + userRole: '유저 roel', + }, + ], + + message: { + code: '200', + message: '성공적으로 조회되었습니다.', + }, + }, + }, + }), +}; + +export const getTagsDoc = { + ApiOperation: ApiOperation({ + summary: '태그 데이터 조회', + description: 'DB에 저장되어 있는 태그 데이터를 조회합니다 ', + }), + + ApiResponse: ApiResponse({ + status: 200, + description: '피드 데이터 조회 성공', + schema: { + example: { + tags: [ + { id: 1, name: '고민' }, + { id: 2, name: '회고' }, + { id: 3, name: '아이디어' }, + { id: 4, name: '계획' }, + { id: 5, name: '토론' }, + { id: 6, name: '정보공유' }, + { id: 7, name: '추천' }, + { id: 8, name: '질문' }, + ], + message: { + code: 200, + message: '태그가 성공적으로 조회되었습니다.', + }, + }, + }, + }), +}; + +export const getFeedDocs = { + ApiOperation: ApiOperation({ + summary: '피드 개별 조회 (게시글)', + description: '개별 피드를 조회합니다', + }), + ApiParam: ApiParam({ + name: 'id', + required: true, + description: '조회할 피드 아이디', + }), + + ApiResponse: ApiResponse({ + status: 200, + description: '개별 피드 조회 성공', + schema: { + example: { + post: { + userId: '작성자 아이디', + userName: '작성자 이름', + userNickname: '작성자 닉네임', + userRole: '작성자 role', + userProfileUrl: '작성자 프로필 사진 주소', + postId: '게시글 아이디', + title: '게시글 제목', + thumbnailUrl: '게시글 썸네일 사진 주소', + content: '게시글 내용', + tags: ['게시글 태그1', '게시글 태그2'], + commentCount: '댓글 수', + likeCount: '좋아요 수', + viewCount: '조회 수', + isLiked: '좋아요 여부', + createdAt: '작성 시간', + }, + message: { + code: 200, + message: '개별 피드를 정상적으로 조회했습니다.', + }, + }, + }, + }), +}; + +export const getFeedCommentDocs = { + ApiOperation: ApiOperation({ + summary: '피드 개별 조회 (댓글)', + description: '개별 피드의 댓글을 조회합니다', + }), + + ApiParam: ApiParam({ + name: 'id', + required: true, + description: '조회할 피드의 아이디', + }), + + ApiResponse: ApiResponse({ + status: 200, + description: '댓글 조회 성공', + schema: { + example: { + comments: [ + { + commentId: '댓글 아이디', + userId: '댓글 작성자 아이디', + userName: '작성자 이름', + userRole: '작성자 role', + userProfileUrl: '작성자 프로필 사진 주소', + comment: '댓글 내용', + createdAt: '작성 시간', + likeCount: '좋아요 수', + isLiked: '좋아요 여부', + }, + { + commentId: '댓글 아이디', + userId: '댓글 작성자 아이디', + userName: '작성자 이름', + userRole: '작성자 role', + userProfileUrl: '작성자 프로필 사진 주소', + comment: '댓글 내용', + createdAt: '작성 시간', + likeCount: '좋아요 수', + isLiked: '좋아요 여부', + }, + ], + message: { + code: 200, + message: '개별 피드(댓글)를 정상적으로 조회했습니다.', + }, + }, + }, + }), +}; + +export const handleFeedLikesDocs = { + ApiBearerAuth: ApiBearerAuth(), + + ApiOperation: ApiOperation({ + summary: '피드 좋아요 추가/제거', + description: '요청 시 좋아요 여부에 따라 좋아요가 추가/제거 됩니다', + }), + + ApiParam: ApiParam({ + name: 'id', + required: true, + description: '피드 아이디', + }), + + ApiResponse: ApiResponse({ + status: 200, + description: '좋아요 추가/제거 성공', + schema: { + examples: [ + { + message: { + code: 200, + message: '좋아요가 취소되었습니다.', + }, + }, + { + message: { + code: 200, + message: '좋아요가 추가되었습니다.', + }, + }, + ], + }, + }), +}; + +export const createFeedDocs = { + ApiBearerAuth: ApiBearerAuth(), + ApiOperation: ApiOperation({ + summary: '피드 등록', + description: '피드를 등록합니다', + }), + + ApiBody: ApiBody({ + description: '등록할 피드 데이터', + schema: { + type: 'object', + properties: { + title: { + type: 'string', + description: '피드 제목', + }, + tags: { + type: 'string[]', + description: '피드 태그 목록', + example: ['고민', '회고'], + }, + content: { + type: 'string', + description: '피드 내용', + }, + }, + required: ['title', 'tags', 'content'], + }, + }), + + ApiResponse: ApiResponse({ + status: 201, + description: '피드 등록 성공', + schema: { + example: { + message: { + code: 201, + message: '피드 작성이 완료되었습니다.', + }, + post: { + id: '게시글 아이디', + }, + }, + }, + }), +}; + +export const updateFeedDocs = { + ApiBearerAuth: ApiBearerAuth(), + ApiOperation: ApiOperation({ + summary: '피드 수정', + description: '피드를 수정합니다', + }), + + ApiBody: ApiBody({ + description: '수정할 피드 데이터', + schema: { + type: 'object', + properties: { + title: { + type: 'string', + description: '수정 or 기존 피드 제목', + }, + tags: { + type: 'string[]', + description: '수정 or 기존 피드 태그 목록', + example: ['고민', '회고'], + }, + content: { + type: 'string', + description: '수정 or 기존 피드 내용', + }, + }, + required: ['title', 'tags', 'content'], + }, + }), + + ApiResponse: ApiResponse({ + status: 200, + description: '피드 수정 완료', + schema: { + example: { + message: { + code: 200, + message: '피드 수정이 완료되었습니다.', + }, + }, + }, + }), +}; + +export const deleteFeedDocs = { + ApiBearerAuth: ApiBearerAuth(), + ApiOperation: ApiOperation({ + summary: '피드 삭제', + description: '피드를 삭제합니다', + }), + + ApiParam: ApiParam({ + name: 'id', + required: true, + description: '삭제할 피드 아이디', + }), + + ApiResponse: ApiResponse({ + status: 200, + description: '피드 삭제 성공', + schema: { + example: { + message: { + code: 200, + message: '피드가 삭제되었습니다.', + }, + }, + }, + }), +}; + +export const createCommentDocs = { + ApiBearerAuth: ApiBearerAuth(), + ApiOperation: ApiOperation({ + summary: '댓글 등록', + description: '피드에 댓글을 등록합니다', + }), + + ApiParam: ApiParam({ + name: 'id', + required: true, + description: '댓글을 작성할 피드 아이디', + }), + + ApiBody: ApiBody({ + description: '등록할 댓글 데이터', + schema: { + type: 'object', + properties: { + content: { + type: 'string', + description: '댓글 내용', + }, + }, + }, + }), + + ApiResponse: ApiResponse({ + status: 201, + description: '댓글 등록 성공', + schema: { + example: { + message: { + code: 201, + message: '댓글이 등록이 완료되었습니다.', + }, + }, + }, + }), +}; + +export const updateCommentDocs = { + ApiBearerAuth: ApiBearerAuth(), + ApiOperation: ApiOperation({ + summary: '댓글 수정', + description: '기존 댓글을 수정합니다', + }), + + ApiParam: ApiParam({ + name: 'id', + required: true, + description: '댓글이 작성된 피드 아이디', + }), + + ApiParam2: ApiParam({ + name: 'commentId', + required: true, + description: '댓글 아이디', + }), + + ApiBody: ApiBody({ + description: '수정할 댓글 데이터', + schema: { + type: 'object', + properties: { + content: { + type: 'string', + description: '수정 댓글 내용', + }, + }, + }, + }), + + ApiResponse: ApiResponse({ + status: 201, + description: '댓글 수정 성공', + schema: { + example: { + message: { + code: 201, + message: '댓글 수정이 완료되었습니다.', + }, + }, + }, + }), +}; + +export const deleteCommentDocs = { + ApiBearerAuth: ApiBearerAuth(), + ApiOperation: ApiOperation({ + summary: '댓글 삭제', + description: '댓글을 삭제합니다', + }), + + ApiParam: ApiParam({ + name: 'id', + required: true, + description: '삭제할 댓글 아이디', + }), + + ApiResponse: ApiResponse({ + status: 200, + description: '댓글 삭제 성공', + schema: { + example: { + message: { + code: 200, + message: '댓글이 삭제되었습니다.', + }, + }, + }, + }), +}; + +export const handleCommentLikesDocs = { + ApiBearerAuth: ApiBearerAuth(), + + ApiOperation: ApiOperation({ + summary: '댓글 좋아요 추가/제거', + description: '요청 시 좋아요 여부에 따라 좋아요가 추가/제거 됩니다', + }), + + ApiParam: ApiParam({ + name: 'id', + required: true, + description: '댓글 아이디', + }), + + ApiResponse: ApiResponse({ + status: 200, + description: '좋아요 추가/제거 성공', + schema: { + examples: [ + { + message: { + code: 200, + message: '좋아요가 취소되었습니다.', + }, + }, + { + message: { + code: 200, + message: '좋아요가 추가되었습니다.', + }, + }, + ], + }, + }), +}; + +export const uploadImageDocs = { + ApiBearerAuth: ApiBearerAuth(), + ApiOperation: ApiOperation({ + summary: '이미지 업로드', + description: '이미지 업로드 시 이미지 링크를 반환합니다', + }), + + ApiConsumes: ApiConsumes('multipart/form-data'), + ApiBody: ApiBody({ + description: '업로드할 이미지 파일', + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + }, + }, + }, + }), + + ApiResponse: ApiResponse({ + status: 200, + description: '이미지 업로드 성공', + schema: { + example: { + imageUrl: '이미지 url', + message: { + code: 200, + message: '이미지 업로드가 완료되었습니다.', + }, + }, + }, + }), +}; diff --git a/src/feed/dto/comment.dto.ts b/src/feed/dto/comment.dto.ts new file mode 100644 index 0000000..a6b1640 --- /dev/null +++ b/src/feed/dto/comment.dto.ts @@ -0,0 +1,7 @@ +import { IsString, IsNotEmpty } from 'class-validator'; + +export class CommentDto { + @IsString({ message: '내용은 문자열이어야 합니다.' }) + @IsNotEmpty({ message: '내용을 입력해주세요' }) + content: string; +} diff --git a/src/feed/dto/feed.dto.ts b/src/feed/dto/feed.dto.ts new file mode 100644 index 0000000..d4ce7bd --- /dev/null +++ b/src/feed/dto/feed.dto.ts @@ -0,0 +1,16 @@ +import { IsString, IsArray, ArrayNotEmpty, IsNotEmpty } from 'class-validator'; + +export class FeedDto { + @IsString({ message: '제목은 문자열이어야 합니다.' }) + @IsNotEmpty({ message: '제목을 입력해주세요.' }) + title: string; + + @IsArray({ message: '태그는 배열이어야 합니다.' }) + @ArrayNotEmpty({ message: '태그를 하나 이상 입력해주세요' }) + @IsString({ each: true }) + tags: string[]; + + @IsString({ message: '내용은 문자열이어야 합니다.' }) + @IsNotEmpty({ message: '내용을 입력해주세요' }) + content: string; +} diff --git a/src/feed/dto/getFeedsQuery.dto.ts b/src/feed/dto/getFeedsQuery.dto.ts new file mode 100644 index 0000000..840d76f --- /dev/null +++ b/src/feed/dto/getFeedsQuery.dto.ts @@ -0,0 +1,25 @@ +import { IsOptional, IsInt, IsBoolean } from 'class-validator'; +import { Transform, Type } from 'class-transformer'; + +export class GetFeedsQueryDto { + // 최신순 + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === 'true') + latest?: boolean; + + // 리밋 + @IsOptional() + @IsInt() + @Type(() => Number) + limit?: number; + + // 커서 + @IsOptional() + @IsInt() + @Type(() => Number) + cursor?: number; + + @IsOptional() + tags?: string; +} diff --git a/src/feed/feed.controller.spec.ts b/src/feed/feed.controller.spec.ts new file mode 100644 index 0000000..d80197a --- /dev/null +++ b/src/feed/feed.controller.spec.ts @@ -0,0 +1,20 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { FeedController } from './feed.controller'; +import { FeedService } from './feed.service'; + +describe('FeedController', () => { + let controller: FeedController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [FeedController], + providers: [FeedService], + }).compile(); + + controller = module.get(FeedController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/feed/feed.controller.ts b/src/feed/feed.controller.ts new file mode 100644 index 0000000..2550fe4 --- /dev/null +++ b/src/feed/feed.controller.ts @@ -0,0 +1,223 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query, + Req, + UploadedFile, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { FeedService } from './feed.service'; +import { OptionalAuthGuard } from '@src/modules/auth/guards/optional-auth.guard'; +import { JwtAuthGuard } from '@src/modules/auth/guards/jwt-auth.guard'; +import { FeedDto } from './dto/feed.dto'; +import { CommentDto } from './dto/comment.dto'; +import { GetFeedsQueryDto } from './dto/getFeedsQuery.dto'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { + createCommentDocs, + createFeedDocs, + deleteCommentDocs, + deleteFeedDocs, + getFeedCommentDocs, + getFeedDocs, + getMainPageDocs, + getTagsDoc, + getWeeklyBestDocs, + handleCommentLikesDocs, + handleFeedLikesDocs, + updateCommentDocs, + updateFeedDocs, + uploadImageDocs, +} from './docs/feed.docs'; + +@Controller('feed') +export class FeedController { + constructor(private readonly feedService: FeedService) {} + // 메인 페이지 조회 + @Get() + @getMainPageDocs.ApiOperation + @getMainPageDocs.ApiQuery + @getMainPageDocs.ApiQuery2 + @getMainPageDocs.ApiQuery3 + @getMainPageDocs.ApiResponse + @UseGuards(OptionalAuthGuard) + async getAllFeed(@Req() req, @Query() queryDto: GetFeedsQueryDto) { + return this.feedService.getAllFeeds(req.user, queryDto); + } + + // 태그 데이터 조회 + @Get('/tags') + @getTagsDoc.ApiOperation + @getTagsDoc.ApiResponse + async getTags() { + return this.feedService.getTags(); + } + + // 위클리 베스트 컨텐츠 + @Get('/weekly') + @getWeeklyBestDocs.ApiOperation + @getWeeklyBestDocs.ApiResponse + async getWeeklyBest() { + return this.feedService.getWeeklyBest(); + } + + // 피드 조회 (게시글) + @Get(':id') + @UseGuards(OptionalAuthGuard) + @getFeedDocs.ApiOperation + @getFeedDocs.ApiParam + @getFeedDocs.ApiResponse + async getFeed(@Param('id') feedId: number, @Req() req) { + return await this.feedService.getFeed(feedId, req.user); + } + + // 피드 조회 (댓글) + @Get(':id/comments') + @UseGuards(OptionalAuthGuard) + @getFeedCommentDocs.ApiOperation + @getFeedCommentDocs.ApiParam + @getFeedCommentDocs.ApiResponse + async getFeedComments(@Param('id') feedId: number, @Req() req) { + return await this.feedService.getFeedComments(feedId, req.user); + } + + // 좋아요 추가/ 제거 + @Post(':id/likes') + @UseGuards(JwtAuthGuard) + @handleFeedLikesDocs.ApiBearerAuth + @handleFeedLikesDocs.ApiOperation + @handleFeedLikesDocs.ApiParam + @handleFeedLikesDocs.ApiResponse + async handleFeedLikes(@Req() req, @Param('id') feedId: number) { + const userId = req.user.user_id; + return await this.feedService.handleFeedLikes(feedId, userId); + } + + // 피드 등록 + @Post() + @UseGuards(JwtAuthGuard) + @createFeedDocs.ApiBearerAuth + @createFeedDocs.ApiOperation + @createFeedDocs.ApiBody + @createFeedDocs.ApiResponse + async createFeed(@Req() req, @Body() feedDto: FeedDto) { + const userId = req.user.user_id; + return this.feedService.createFeed(feedDto, userId); + } + + // 피드 수정 + @Put(':id') + @UseGuards(JwtAuthGuard) + @updateFeedDocs.ApiBearerAuth + @updateFeedDocs.ApiOperation + @updateFeedDocs.ApiBody + @updateFeedDocs.ApiResponse + async updateFeed( + @Req() req, + @Body() feedDto: FeedDto, + @Param('id') feedId: number + ) { + const userId = req.user.user_id; + return this.feedService.updateFeed(feedDto, feedId, userId); + } + + // 피드 삭제 + @Delete(':id') + @UseGuards(JwtAuthGuard) + @deleteFeedDocs.ApiBearerAuth + @deleteFeedDocs.ApiOperation + @deleteFeedDocs.ApiParam + @deleteFeedDocs.ApiResponse + async deleteFeed(@Req() req, @Param('id') feedId: number) { + const userId = req.user.user_id; + return this.feedService.deleteFeed(userId, feedId); + } + + // 댓글 등록 + @Post(':id/comment') + @UseGuards(JwtAuthGuard) + @createCommentDocs.ApiBearerAuth + @createCommentDocs.ApiOperation + @createCommentDocs.ApiParam + @createCommentDocs.ApiBody + @createCommentDocs.ApiResponse + async createComment( + @Req() req, + @Param('id') feedId: number, + @Body() commentDto: CommentDto + ) { + const userId = req.user.user_id; + return this.feedService.createComment(userId, feedId, commentDto); + } + + // 댓글 수정 + @Put(':id/comment/:commentId') + @UseGuards(JwtAuthGuard) + @updateCommentDocs.ApiBearerAuth + @updateCommentDocs.ApiOperation + @updateCommentDocs.ApiParam + @updateCommentDocs.ApiParam2 + @updateCommentDocs.ApiBody + @updateCommentDocs.ApiResponse + async updateComment( + @Req() req, + @Param('id') feedId: number, + @Param('commentId') commentId: number, + @Body() commentDto: CommentDto + ) { + const userId = req.user.user_id; + return await this.feedService.updateComment( + userId, + feedId, + commentId, + commentDto + ); + } + + // 댓글 삭제 + @Delete(':id/comment/:commentId') + @UseGuards(JwtAuthGuard) + @deleteCommentDocs.ApiBearerAuth + @deleteCommentDocs.ApiOperation + @deleteCommentDocs.ApiParam + @deleteCommentDocs.ApiResponse + async deleteComment( + @Req() req, + @Param('id') feedId: number, + @Param('commentId') commentId: number + ) { + const userId = req.user.user_id; + return this.feedService.deleteComment(userId, feedId, commentId); + } + + @Post('comment/:id') + @UseGuards(JwtAuthGuard) + @handleCommentLikesDocs.ApiBearerAuth + @handleCommentLikesDocs.ApiOperation + @handleCommentLikesDocs.ApiParam + @handleCommentLikesDocs.ApiResponse + async handleCommentLikes(@Req() req, @Param('id') commentId: number) { + const userId = req.user.user_id; + return await this.feedService.handleCommentLikes(userId, commentId); + } + + // 이미지 업로드 + @Post('image') + @UseInterceptors(FileInterceptor('file')) + @UseGuards(JwtAuthGuard) + @uploadImageDocs.ApiBearerAuth + @uploadImageDocs.ApiOperation + @uploadImageDocs.ApiConsumes + @uploadImageDocs.ApiBody + @uploadImageDocs.ApiResponse + async func(@Req() req, @UploadedFile() file: Express.Multer.File) { + const userId = req.user.user_id; + return await this.feedService.uploadFeedImage(userId, file); + } +} diff --git a/src/feed/feed.module.ts b/src/feed/feed.module.ts new file mode 100644 index 0000000..3654747 --- /dev/null +++ b/src/feed/feed.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { FeedService } from './feed.service'; +import { FeedController } from './feed.controller'; +import { PrismaModule } from '@src/prisma/prisma.module'; +import { S3Module } from '@src/s3/s3.module'; +import { NotificationModule } from '@src/modules/notification/notification.module'; + +@Module({ + imports: [PrismaModule, S3Module, NotificationModule], + controllers: [FeedController], + providers: [FeedService], +}) +export class FeedModule {} diff --git a/src/feed/feed.service.spec.ts b/src/feed/feed.service.spec.ts new file mode 100644 index 0000000..38beba7 --- /dev/null +++ b/src/feed/feed.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { FeedService } from './feed.service'; + +describe('FeedService', () => { + let service: FeedService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [FeedService], + }).compile(); + + service = module.get(FeedService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/feed/feed.service.ts b/src/feed/feed.service.ts new file mode 100644 index 0000000..6fa260e --- /dev/null +++ b/src/feed/feed.service.ts @@ -0,0 +1,829 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { PrismaService } from '@src/prisma/prisma.service'; +import { FeedDto } from './dto/feed.dto'; +import * as cheerio from 'cheerio'; +import { CommentDto } from './dto/comment.dto'; +import { GetFeedsQueryDto } from './dto/getFeedsQuery.dto'; +import { S3Service } from '@src/s3/s3.service'; +import * as dayjs from 'dayjs'; +import { NotificationsService } from '@src/modules/notification/notification.service'; + +@Injectable() +export class FeedService { + constructor( + private readonly prisma: PrismaService, + private readonly s3: S3Service, + private readonly notificationsService: NotificationsService + ) {} + + // 피드 전체 조회 + async getAllFeeds(user, queryDto: GetFeedsQueryDto) { + try { + const userId = user ? user.user_id : 0; + const { latest = false, limit = 10, cursor = 0, tags } = queryDto; + + // 쿼리로 전달받은 태그 + const tagIds = tags ? tags.split(',').map(id => parseInt(id)) : []; + + // 태그를 포함하고 있는 피드 아이디 조회 + const feedTagIds = tagIds?.length + ? ( + await this.prisma.feedPostTag.groupBy({ + by: ['post_id'], + where: { tag_id: { in: tagIds } }, + having: { + post_id: { _count: { equals: tagIds.length } }, // 태그 개수 일치하는 게시글만 조회 + }, + }) + ).map(p => p.post_id) + : null; + + // 쿼리값이 인기순이면 따로 처리 + if (!latest) { + return this.getPopularFeed(userId, limit, cursor, feedTagIds); + } + + const result = await this.prisma.feedPost.findMany({ + orderBy: { id: 'desc' }, + where: { + ...(cursor ? { id: { lt: cursor } } : {}), // cursor 조건 추가 (옵셔널) + ...(feedTagIds ? { id: { in: feedTagIds } } : {}), // 태그 조건 추가 (옵셔널) + }, + + take: limit, + + include: { + Likes: { + where: { user_id: userId }, + }, + user: { + select: { + id: true, + name: true, + email: true, + nickname: true, + role: { + select: { name: true }, + }, + profile_url: true, + }, + }, + Tags: { + select: { + tag: { + select: { name: true }, + }, + }, + }, + }, + }); + + const posts = []; + for (const res of result) { + const post = await this.getPostObj(res); + posts.push(post); + } + + // 라스트 커서 + const lastCursor = posts[posts.length - 1]?.postId || null; + + return { + posts, + pagination: { lastCursor }, + message: { code: 200, text: '전체 피드를 정상적으로 조회했습니다.' }, + }; + } catch (err) { + console.log(err); + throw new HttpException( + '서버에서 오류가 발생했습니다.', + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + } + + // 인기 피드 조회 (좋아요 순) + async getPopularFeed( + userId: number, + limit: number, + cursor: number, + feedTagIds + ) { + try { + const result = await this.prisma.feedPost.findMany({ + orderBy: [{ like_count: 'desc' }, { view: 'desc' }], + where: { + ...(feedTagIds ? { id: { in: feedTagIds } } : {}), // 태그 조건 추가 (옵셔널) + }, + skip: cursor * limit, + take: limit, + include: { + Likes: { + where: { user_id: userId }, + }, + user: { + select: { + id: true, + name: true, + email: true, + nickname: true, + role: { + select: { name: true }, + }, + profile_url: true, + }, + }, + Tags: { + select: { + tag: { + select: { name: true }, + }, + }, + }, + }, + }); + + const posts = []; + for (const res of result) { + const post = await this.getPostObj(res); + posts.push(post); + } + + let lastCursor; + if (result.length) { + lastCursor = cursor + 1; + } else { + lastCursor = null; + } + + return { + posts, + pagination: { lastCursor }, + message: { code: 200, text: '전체 피드를 정상적으로 조회했습니다.' }, + }; + } catch (err) { + console.log(err); + throw new HttpException( + '서버에서 오류가 발생했습니다.', + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + } + + // 피드 조회 (게시글 부분) + async getFeed(feedId: number, user) { + try { + const userId = user ? user.user_id : 0; + const result = await this.prisma.feedPost.findUnique({ + where: { id: feedId }, + include: { + Likes: { + where: { user_id: userId }, + }, + user: { + select: { + id: true, + name: true, + email: true, + nickname: true, + role: { + select: { name: true }, + }, + profile_url: true, + }, + }, + Tags: { + select: { + tag: { + select: { name: true }, + }, + }, + }, + }, + }); + + if (!result) { + throw new HttpException( + '게시글을 찾을 수 없습니다', + HttpStatus.NOT_FOUND + ); + } + + const post = await this.getPostObj(result); + + // 조회수 증가 + await this.prisma.feedPost.update({ + where: { id: feedId }, + data: { view: { increment: 1 } }, + }); + + return { + post, + message: { code: 200, text: '개별 피드를 정상적으로 조회했습니다.' }, + }; + } catch (err) { + console.log(err); + if (err instanceof HttpException) { + throw err; + } + + throw new HttpException( + '서버에서 오류가 발생했습니다.', + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + } + + // 피드 데이터 응답 양식 + async getPostObj(result) { + const post = { + userId: result.user.id, + userName: result.user.name, + userNickname: result.user.nickname, + userRole: result.user.role.name, + userProfileUrl: result.user.profile_url, + title: result.title, + postId: result.id, + thumnailUrl: result.thumbnail_url, + content: result.content, + tags: result.Tags.map(v => v.tag.name), + commentCount: result.comment_count, + likeCount: result.like_count, + viewCount: result.view, + createdAt: result.created_at, + isLiked: !!result.Likes.length, + }; + return post; + } + + // 피드 개별 조회 (댓글) + async getFeedComments(feedId: number, user) { + try { + const userId = user ? user.user_id : 0; + + const result = await this.prisma.feedComment.findMany({ + where: { + post_id: feedId, + }, + include: { + user: { + select: { + id: true, + name: true, + email: true, + role: { + select: { name: true }, + }, + profile_url: true, + }, + }, + FeedCommentLikes: true, + }, + }); + + if (!result.length) { + return { + comments: [], + message: { + code: 200, + text: '개별 피드(댓글)를 정상적으로 조회했습니다.', + }, + }; + } + + const comments = result.map(c => ({ + commentId: c.id, + userId: c.user.id, + userName: c.user.name, + userRole: c.user.role.name, + userProfileUrl: c.user.profile_url, + comment: c.content, + likeCount: c.FeedCommentLikes.length, + createdAt: c.created_at, + isLiked: userId + ? !!c.FeedCommentLikes.filter(v => v.user_id == userId).length + : false, + })); + + return { + comments, + message: { + code: 200, + text: '개별 피드(댓글)를 정상적으로 조회했습니다.', + }, + }; + } catch (err) { + console.log(err); + if (err instanceof HttpException) { + throw err; + } + + throw new HttpException( + '서버에서 오류가 발생했습니다.', + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + } + + // 피드 좋아요 추가/제거 + async handleFeedLikes(feedId: number, userId: number) { + try { + const exist = await this.prisma.feedLike.findMany({ + where: { + post_id: feedId, + user_id: userId, + }, + }); + + if (exist.length) { + // 좋아요 취소 + await this.prisma.feedLike.deleteMany({ + where: { + post_id: feedId, + user_id: userId, + }, + }); + + await this.prisma.feedPost.update({ + where: { id: feedId }, + data: { like_count: { decrement: 1 } }, + }); + + return { message: { code: 200, text: '좋아요가 취소되었습니다.' } }; + } else { + // 좋아요 추가 + await this.prisma.feedLike.create({ + data: { + post_id: feedId, + user_id: userId, + }, + }); + + await this.prisma.feedPost.update({ + where: { id: feedId }, + data: { like_count: { increment: 1 } }, + }); + + // 피드 작성자 정보 가져오기 + const feed = await this.prisma.feedPost.findUnique({ + where: { id: feedId }, + include: { user: true }, // 작성자 정보 포함 + }); + + if (!feed) { + throw new HttpException( + '피드를 찾을 수 없습니다.', + HttpStatus.NOT_FOUND + ); + } + + // 본인이 작성한 피드가 아닌 경우 알림 생성 + if (feed.user_id !== userId) { + const sender = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { nickname: true, profile_url: true }, + }); + + if (!sender) { + throw new HttpException( + '보낸 사람 정보를 찾을 수 없습니다.', + HttpStatus.NOT_FOUND + ); + } + + const message = `${sender.nickname}님이 회원님의 피드를 좋아합니다.`; + + // 알림 생성 및 notificationId 받기 + const createdNotification = + await this.notificationsService.createNotification( + feed.user_id, // 피드 작성자 ID + userId, // 좋아요 누른 사용자 ID + 'like', + message + ); + + // 생성된 알림의 notificationId를 포함하여 실시간 알림 전송 + this.notificationsService.sendRealTimeNotification(feed.user_id, { + notificationId: createdNotification.notificationId, // 알림 ID 추가 + type: 'like', + message, + senderNickname: sender.nickname, + senderProfileUrl: sender.profile_url, + }); + } + + return { message: { code: 200, text: '좋아요가 추가되었습니다.' } }; + } + } catch (err) { + console.error(err); + throw new HttpException( + '서버에서 오류가 발생했습니다.', + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + } + + // 피드 등록 + async createFeed(feedDto: FeedDto, userId: number) { + const { title, tags, content } = feedDto; + try { + // 썸네일 url 추출 + const thumnailUrl = await this.getThumnailUrl(content); + + // FeedPost에 피드 데이터 저장 + const feedData = await this.prisma.feedPost.create({ + data: { + user_id: userId, + title, + content, + thumbnail_url: thumnailUrl, + }, + }); + + // 태그 이름으로 태그 id 조회 + const tagIds = await this.prisma.feedTag.findMany({ + where: { name: { in: tags } }, + select: { id: true }, + }); + + // 태그 데이터 양식화 {post_id, tag_id} + const tagData = tagIds.map(tag => ({ + post_id: feedData.id, + tag_id: tag.id, + })); + + // 태그 데이터 저장 + await this.prisma.feedPostTag.createMany({ + data: tagData, + }); + + return { + message: { code: 201, text: '피드 작성이 완료되었습니다.' }, + post: { id: feedData.id }, + }; + } catch (err) { + throw err; + } + } + + // 피드 수정 + async updateFeed(feedDto: FeedDto, feedId: number, userId: number) { + try { + const { title, tags, content } = feedDto; + + const thumnailUrl = (await this.getThumnailUrl(content)) || null; + + // 권한 확인 + const auth = await this.feedAuth(userId, feedId); + + if (!auth) { + throw new HttpException('권한이 없습니다.', HttpStatus.FORBIDDEN); + } + + // 태그 이름으로 태그 id 조회 + const tagIds = await this.prisma.feedTag.findMany({ + where: { name: { in: tags } }, + select: { id: true }, + }); + + const tagData = tagIds.map(tag => ({ + post_id: feedId, + tag_id: tag.id, + })); + + await this.prisma.feedPost.update({ + where: { id: feedId }, + data: { + title, + content, + thumbnail_url: thumnailUrl, + Tags: { + deleteMany: {}, + + create: tagData.map(tag => ({ tag_id: tag.tag_id })), + }, + }, + }); + + return { message: { code: 200, text: '피드 수정이 완료되었습니다.' } }; + } catch (err) { + if (err instanceof HttpException) { + throw err; + } + + throw new HttpException( + '서버에서 오류가 발생했습니다.', + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + } + + // 피드 삭제 + async deleteFeed(userId: number, feedId: number) { + try { + // 권한 확인 + const auth = await this.feedAuth(userId, feedId); + + if (!auth) { + throw new HttpException('권한이 없습니다.', HttpStatus.FORBIDDEN); + } + + // 트랜잭션으로 한번에 처리 + // 태그(매핑 테이블), 댓글, 좋아요(매핑 테이블), 게시글 삭제 + await this.prisma.$transaction([ + this.prisma.feedPostTag.deleteMany({ + where: { post_id: feedId }, + }), + + this.prisma.feedComment.deleteMany({ + where: { post_id: feedId }, + }), + + this.prisma.feedLike.deleteMany({ + where: { post_id: feedId }, + }), + + this.prisma.feedPost.delete({ + where: { id: feedId }, + }), + ]); + + return { message: { code: 200, text: '피드가 삭제되었습니다.' } }; + } catch (err) { + if (err instanceof HttpException) { + throw err; + } + + throw new HttpException( + '서버에서 오류가 발생했습니다.', + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + } + + // 썸네일 추출 + async getThumnailUrl(text: string) { + try { + const $ = cheerio.load(text); + const thumnailUrl = $('img').first().attr('src'); + return thumnailUrl; + } catch (err) { + throw err; + } + } + + // 댓글 등록 + async createComment(userId: number, feedId: number, commentDto: CommentDto) { + try { + const content = commentDto.content; + + // 댓글 생성 + await this.prisma.feedComment.create({ + data: { + user_id: userId, + post_id: feedId, + content, + }, + }); + + // 피드 댓글 카운트 증가 + await this.prisma.feedPost.update({ + where: { id: feedId }, + data: { comment_count: { increment: 1 } }, + }); + + // 피드 작성자 정보 가져오기 + const feed = await this.prisma.feedPost.findUnique({ + where: { id: feedId }, + include: { user: true }, // 작성자 정보 포함 + }); + + if (!feed) { + throw new HttpException( + '피드를 찾을 수 없습니다.', + HttpStatus.NOT_FOUND + ); + } + + // 피드 작성자가 댓글 작성자가 아닌 경우 알림 생성 + if (feed.user_id !== userId) { + const sender = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { nickname: true, profile_url: true }, + }); + + if (!sender) { + throw new HttpException( + '보낸 사람 정보를 찾을 수 없습니다.', + HttpStatus.NOT_FOUND + ); + } + + const message = `${sender.nickname}님이 회원님의 피드에 댓글을 남겼습니다.`; + + // 알림 생성 및 notificationId 받기 + const createdNotification = + await this.notificationsService.createNotification( + feed.user_id, // 피드 작성자 ID + userId, // 댓글 작성자 ID + 'comment', + message + ); + + // 생성된 알림 ID를 포함하여 실시간 알림 전송 + this.notificationsService.sendRealTimeNotification(feed.user_id, { + notificationId: createdNotification.notificationId, // 알림 ID 추가 + type: 'comment', + message, + senderNickname: sender.nickname, + senderProfileUrl: sender.profile_url, + }); + } + + return { message: { code: 201, text: '댓글 등록이 완료되었습니다.' } }; + } catch (err) { + console.error('댓글 등록 중 오류:', err.message); + throw new HttpException( + '서버에서 오류가 발생했습니다.', + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + } + + // 댓글 삭제 + async deleteComment(userId: number, feedId: number, commentId: number) { + try { + // 권한 확인 + const auth = await this.commentAuth(userId, feedId, commentId); + + if (!auth) { + throw new HttpException('권한이 없습니다.', HttpStatus.FORBIDDEN); + } + + await this.prisma.$transaction([ + // 댓글 삭제 + this.prisma.feedComment.delete({ + where: { id: commentId }, + }), + // 피드 댓글 수 감소 + this.prisma.feedPost.update({ + where: { id: feedId }, + data: { comment_count: { decrement: 1 } }, + }), + ]); + return { message: { code: 200, text: '댓글이 삭제되었습니다.' } }; + } catch (err) { + if (err instanceof HttpException) { + throw err; + } + + throw new HttpException( + '서버에서 오류가 발생했습니다.', + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + } + + // 댓글 수정 + async updateComment( + userId: number, + feedId: number, + commentId: number, + commentDto: CommentDto + ) { + // 권한 확인 + const auth = await this.commentAuth(userId, feedId, commentId); + + if (!auth) { + throw new HttpException('권한이 없습니다.', HttpStatus.FORBIDDEN); + } + + const content = commentDto.content; + await this.prisma.feedComment.update({ + where: { id: commentId }, + data: { content }, + }); + + return { message: { code: 200, text: '댓글 수정이 완료되었습니다.' } }; + } + + // 댓글 좋아요 추가/제거 + async handleCommentLikes(userId: number, commentId: number) { + // 좋아요 여부 확인 + const exist = await this.prisma.feedCommentLikes.findMany({ + where: { user_id: userId, comment_id: commentId }, + }); + + if (exist.length) { + // 있을 시 좋아요 제거 + await this.prisma.feedCommentLikes.deleteMany({ + where: { user_id: userId, comment_id: commentId }, + }); + + return { message: { code: 200, text: '좋아요가 취소되었습니다.' } }; + } else { + // 없을 시 좋아요 추가 + await this.prisma.feedCommentLikes.create({ + data: { user_id: userId, comment_id: commentId }, + }); + return { message: { code: 200, text: '좋아요가 추가되었습니다.' } }; + } + } + + // 게시글 권한 확인 + async feedAuth(userId: number, feedId: number) { + const auth = await this.prisma.feedPost.findUnique({ + where: { id: feedId }, + select: { user_id: true }, + }); + + return auth.user_id === userId; + } + + // 댓글 권한 확인 + async commentAuth(userId: number, feedId: number, commentId: number) { + const auth = await this.prisma.feedComment.findUnique({ + where: { id: commentId }, + select: { user_id: true, post_id: true }, + }); + + if (feedId !== auth.post_id) { + throw new HttpException('잘못된 요청입니다', HttpStatus.BAD_REQUEST); + } + + return auth.user_id == userId; + } + + // 이미지 업로드 + async uploadFeedImage(userId: number, file: Express.Multer.File) { + const fileType = file.mimetype.split('/')[1]; + const imageUrl = await this.s3.uploadImage( + 8, + file.buffer, + fileType, + 'pad_feed/images' + ); + + return { + imageUrl, + message: { code: 200, text: '이미지 업로드가 완료되었습니다.' }, + }; + } + + async getTags() { + const tags = await this.prisma.feedTag.findMany(); + + return { + tags, + message: { code: 200, text: '태그가 성공적으로 조회되었습니다.' }, + }; + } + + async getWeeklyBest() { + // 현재 날짜 + const now = dayjs(); + // 이번주 시작(일요일) + const start = now.startOf('week').toDate(); + // 이번주 끝(토요일) + const end = now.endOf('week').toDate(); + + const result = await this.prisma.feedPost.findMany({ + where: { + created_at: { + gte: start, + lte: end, + }, + }, + // 1순위 좋아요 수, 2순위 조회수 + orderBy: [{ like_count: 'desc' }, { view: 'desc' }], + select: { + id: true, + title: true, + user: { + select: { + id: true, + name: true, + nickname: true, + profile_url: true, + role: { select: { name: true } }, + }, + }, + }, + take: 5, + }); + + const contents = result.map(res => ({ + postId: res.id, + title: res.title, + userId: res.user.id, + userName: res.user.name, + userNickname: res.user.nickname, + userProfileUrl: res.user.profile_url, + userRole: res.user.role.name, + })); + + return { + contents, + message: { code: 200, text: '성공적으로 조회되었습니다.' }, + }; + } +} diff --git a/src/main.ts b/src/main.ts index 0f27b22..65eadb9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,17 +3,49 @@ import { AppModule } from './app.module'; import { HttpExceptionFilter } from '@common/filters/http-exception.filter'; import { config } from 'dotenv'; import * as cookieParser from 'cookie-parser'; +import { ValidationPipe } from '@nestjs/common'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; config(); async function bootstrap() { const app = await NestFactory.create(AppModule); app.use(cookieParser()); app.enableCors({ - origin: ['http://localhost:5173', 'http://localhost:8080'], + origin: [ + 'http://localhost:5173', + 'http://localhost:8080', + 'https://p-a-d.store', + ], + methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'], // 💡 GET 포함 credentials: true, - exposedHeaders: ['Authorization'], + allowedHeaders: ['Authorization', 'Content-Type'], // 💡 CORS 요청 헤더 허용 + exposedHeaders: ['Authorization'], // 💡 클라이언트에서 응답 헤더 사용 가능 }); - app.useGlobalFilters(new HttpExceptionFilter()); + + app.useGlobalPipes( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + }) + ); + + const config = new DocumentBuilder() + .setTitle('PAD API') + .setDescription('PAD를 위한 REST API 문서입니다.') + .setVersion('1.0') + .addBearerAuth() // JWT 인증 추가 + .build(); + + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api-docs', app, document, { + swaggerOptions: { + url: '/api-docs', // Swagger 문서 경로와 맞추기 위해 설정 + }, + customSiteTitle: 'API 문서', + }); + + //app.useGlobalFilters(new HttpExceptionFilter()); await app.listen(process.env.PORT); } bootstrap(); diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 945ded4..e5f6cf5 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -9,14 +9,16 @@ import { HttpException, Res, HttpStatus, + HttpCode, } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { AuthService } from '@src/modules/auth/auth.service'; import { JwtAuthGuard } from '@src/modules/auth/guards/jwt-auth.guard'; import { JwtService } from '@nestjs/jwt'; -import { ApiResponse } from '@common/dto/response.dto'; +import { MyApiResponse } from '@common/dto/response.dto'; import { HttpStatusCodes } from '@common/constants/http-status-code'; import { Response } from 'express'; +import { ApiBody, ApiOperation, ApiResponse } from '@nestjs/swagger'; @Controller('auth') export class AuthController { constructor( @@ -74,15 +76,10 @@ export class AuthController { return res.status(HttpStatusCodes.OK).json(response); } - // @Post('login') - // async login( - // @Body() - // ) - // Role 선택 API @Put('roleselect') @UseGuards(JwtAuthGuard) async selectRole( - @Body('role_id') roleId: number, + @Body('roleId') roleId: number, @Req() req: any, @Res() res: Response ) { @@ -102,7 +99,7 @@ export class AuthController { @Post('refresh') async refreshAccessToken( - @Body('user_id') userId: number, // user_id는 숫자로 받음 + @Body('userId') userId: number, // user_id는 숫자로 받음 @Res() res: Response ) { if (userId === undefined || userId === null) { @@ -119,7 +116,7 @@ export class AuthController { code: 200, text: 'Access token이 성공적으로 갱신 되었습니다.', }, - access_token: newAccessToken, + accessToken: newAccessToken, }; return res.status(200).json(response); @@ -135,10 +132,51 @@ export class AuthController { await this.authService.deleteRefreshToken(userId); return res .status(HttpStatusCodes.OK) - .json(new ApiResponse(HttpStatusCodes.OK, '로그아웃 성공')); + .json(new MyApiResponse(HttpStatusCodes.OK, '로그아웃 성공')); } @Post('signup') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: '회원가입', + description: '이메일, 닉네임, 비밀번호로 회원가입을 합니다.', + }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: '회원가입 성공', + schema: { + example: { + message: '일반 회원가입 성공', + user: { + userId: 1, + email: 'user@example.com', + nickname: 'nickname123', + }, + }, + }, + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: '이메일이 이미 존재할 경우', + schema: { + example: { + statusCode: 400, + message: 'Email already exists', + }, + }, + }) + @ApiBody({ + description: '회원가입 요청 데이터', + schema: { + type: 'object', + properties: { + email: { type: 'string', example: 'user@example.com' }, + nickname: { type: 'string', example: 'nickname123' }, + password: { type: 'string', example: 'password123' }, + }, + required: ['email', 'nickname', 'password'], + }, + }) async signup( @Body() body: { email: string; nickname: string; password: string } ) { @@ -148,18 +186,63 @@ export class AuthController { body.password ); return { - message: 'Signup successful', + message: '일반 회원가입 성공', user: result, }; } @Post('login') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '로그인', + description: '이메일과 비밀번호로 로그인을 수행합니다.', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: '로그인 성공', + schema: { + example: { + message: '일반 로그인 성공', + user: { + userId: 1, + email: 'user@example.com', + name: 'nickname123', + nickname: 'nickname123', + profileUrl: null, + authProvider: 'pad', + roleId: 1, + }, + accessToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + }, + }, + }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: '잘못된 이메일 또는 비밀번호', + schema: { + example: { + statusCode: 401, + message: '유효하지 않는 이메일 또는 비밀번호 입니다', + }, + }, + }) + @ApiBody({ + description: '로그인 요청 데이터', + schema: { + type: 'object', + properties: { + email: { type: 'string', example: 'user@example.com' }, + password: { type: 'string', example: 'password123' }, + }, + required: ['email', 'password'], + }, + }) async login(@Body() body: { email: string; password: string }) { const result = await this.authService.login(body.email, body.password); return { - message: 'Login successful', + message: '일반 로그인 성공', user: result.user, - access_token: result.accessToken, + accessToken: result.accessToken, }; } } diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 01a067d..f3cc7c4 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -80,7 +80,7 @@ export class AuthService { code, client_id: process.env.GITHUB_CLIENT_ID, client_secret: process.env.GITHUB_CLIENT_SECRET, - redirect_uri: process.env.GITHUB_CALLBACK_URL, + redirect_uri: process.env.GITHUB_CALLBACK_DEPLOY_URL, }, { headers: { Accept: 'application/json' }, @@ -182,19 +182,36 @@ export class AuthService { // 회원가입 로직 async signup(email: string, nickname: string, password: string) { // 이메일 중복 확인 - const existingUser = await this.prisma.user.findUnique({ + const existingUserByEmail = await this.prisma.user.findUnique({ where: { email }, }); - if (existingUser) { + if (existingUserByEmail) { throw new HttpException('Email already exists', HttpStatus.BAD_REQUEST); } + // 닉네임 중복 확인 및 처리 + let uniqueNickname = nickname; + let isNicknameUnique = false; + + while (!isNicknameUnique) { + const existingUserByNickname = await this.prisma.user.findUnique({ + where: { nickname: uniqueNickname }, + }); + + if (!existingUserByNickname) { + isNicknameUnique = true; // 중복되지 않은 닉네임 + } else { + // 닉네임 뒤에 랜덤 문자열 추가 + uniqueNickname = `${nickname}_${Math.floor(1000 + Math.random() * 9000)}`; // 랜덤 4자리 숫자 추가 + } + } + // 새로운 사용자 생성 const newUser = await this.prisma.user.create({ data: { email, - name: nickname, - nickname, + name: uniqueNickname, + nickname: uniqueNickname, password, auth_provider: 'pad', // 소셜 로그인과 구분 role: { connect: { id: 1 } }, @@ -203,7 +220,7 @@ export class AuthService { }); return { - user_id: newUser.id, + userId: newUser.id, email: newUser.email, nickname: newUser.nickname, }; @@ -217,8 +234,8 @@ export class AuthService { }); if (!user || user.password !== password) { throw new HttpException( - 'Invalid email or password', - HttpStatus.UNAUTHORIZED + '유효하지 않는 이메일 혹은 비밀번호 입니다', + HttpStatus.FORBIDDEN ); } @@ -236,13 +253,13 @@ export class AuthService { } private filterUserFields(user: any) { return { - user_id: user.id, + userId: user.id, email: user.email, name: user.name, nickname: user.nickname, - profile_url: user.profile_url, - auth_provider: user.auth_provider, - role_id: user.role_id, + profileUrl: user.profile_url, + authProvider: user.auth_provider, + roleId: user.role_id, }; } diff --git a/src/modules/auth/guards/optional-auth.guard.ts b/src/modules/auth/guards/optional-auth.guard.ts new file mode 100644 index 0000000..ef430c2 --- /dev/null +++ b/src/modules/auth/guards/optional-auth.guard.ts @@ -0,0 +1,10 @@ +import { Injectable, ExecutionContext } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class OptionalAuthGuard extends AuthGuard('jwt') { + handleRequest(err, user, info) { + // 에러가 있거나 사용자가 없을 경우에도 통과 + return user || null; + } +} diff --git a/src/modules/auth/strategies/github.strategy.ts b/src/modules/auth/strategies/github.strategy.ts index 31cadc3..98f12fe 100644 --- a/src/modules/auth/strategies/github.strategy.ts +++ b/src/modules/auth/strategies/github.strategy.ts @@ -9,7 +9,7 @@ export class GitHubStrategy extends PassportStrategy(Strategy, 'github') { super({ clientID: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET, - callbackURL: 'http://localhost:5173/auth/github/callback', + callbackURL: process.env.GITHUB_CALLBACK_DEPLOY_URL, scope: ['user:email'], }); } diff --git a/src/modules/auth/strategies/google.strategy.ts b/src/modules/auth/strategies/google.strategy.ts index 2b7a85b..6d23dba 100644 --- a/src/modules/auth/strategies/google.strategy.ts +++ b/src/modules/auth/strategies/google.strategy.ts @@ -9,7 +9,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { super({ clientID: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, - callbackURL: 'http://localhost:5173/auth/google/callback', + callbackURL: process.env.GOOGLE_CALLBACK_DEVELOP_URL, scope: ['email', 'profile'], }); } diff --git a/src/modules/follow/follow.controller.ts b/src/modules/follow/follow.controller.ts new file mode 100644 index 0000000..4ad3320 --- /dev/null +++ b/src/modules/follow/follow.controller.ts @@ -0,0 +1,19 @@ +import { Controller, Param, Post, Req, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '@src/modules/auth/guards/jwt-auth.guard'; +import { FollowService } from '@modules/follow/follow.service'; + +@UseGuards(JwtAuthGuard) +@Controller('follow') +export class FollowController { + constructor(private readonly followService: FollowService) {} + + @Post(':targetUserId') + async togggleFollow( + @Req() req: any, + @Param('targetUserId') targetUserId: string + ) { + const userId = req.user.user_id; + const numTargetUserId = parseInt(targetUserId); + return this.followService.toggleFollow(userId, numTargetUserId); + } +} diff --git a/src/modules/follow/follow.module.ts b/src/modules/follow/follow.module.ts new file mode 100644 index 0000000..9fa3c00 --- /dev/null +++ b/src/modules/follow/follow.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { FollowController } from './follow.controller'; +import { FollowService } from '@modules/follow/follow.service'; +import { NotificationModule } from '../notification/notification.module'; +import { PrismaService } from '@src/prisma/prisma.service'; +@Module({ + imports: [NotificationModule], + controllers: [FollowController], + providers: [FollowService, PrismaService], +}) +export class FollowModule {} diff --git a/src/modules/follow/follow.service.ts b/src/modules/follow/follow.service.ts new file mode 100644 index 0000000..680ab42 --- /dev/null +++ b/src/modules/follow/follow.service.ts @@ -0,0 +1,79 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '@prisma/prisma.service'; +import { NotificationsService } from '@modules/notification/notification.service'; + +@Injectable() +export class FollowService { + constructor( + private readonly prisma: PrismaService, + private readonly notificationsService: NotificationsService + ) {} + + async toggleFollow(userId: number, targetUserId: number) { + const existingFollow = await this.prisma.follows.findFirst({ + where: { + following_user_id: userId, + followed_user_id: targetUserId, + }, + }); + + if (existingFollow) { + await this.prisma.follows.delete({ + where: { id: existingFollow.id }, + }); + + return { + message: { code: 200, text: '언팔로우 성공' }, + isFollowing: false, + }; + } else { + await this.prisma.follows.create({ + data: { + following_user_id: userId, + followed_user_id: targetUserId, + }, + }); + + const sender = await this.prisma.user.findUnique({ + where: { id: userId }, + }); + const targetUser = await this.prisma.user.findUnique({ + where: { id: targetUserId }, + }); + + if (!sender || !targetUser) { + throw new Error('사용자 정보를 찾을 수 없습니다.'); + } + + const message = `${sender.nickname}님이 회원님을 팔로우하기 시작했습니다.`; + + // 알림 생성 및 생성된 알림의 id 포함 + const createdNotification = + await this.notificationsService.createNotification( + targetUserId, + userId, + 'follow', + message + ); + + const notificationData = { + notificationId: createdNotification.notificationId, // 포함된 notificationId + type: 'follow', + message, + senderNickname: sender.nickname, + senderProfileUrl: sender.profile_url, + }; + + // SSE를 통해 실시간 알림 전송 + this.notificationsService.sendRealTimeNotification( + targetUserId, + notificationData + ); + + return { + message: { code: 200, text: '팔로우 성공' }, + isFollowing: true, + }; + } + } +} diff --git a/src/modules/notification/Interceptors/notification.interceptor.ts b/src/modules/notification/Interceptors/notification.interceptor.ts new file mode 100644 index 0000000..8bae288 --- /dev/null +++ b/src/modules/notification/Interceptors/notification.interceptor.ts @@ -0,0 +1,25 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Response } from 'express'; +import { Observable } from 'rxjs'; + +@Injectable() +export class SseInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + const res: Response = context.switchToHttp().getResponse(); + + if (!res.headersSent) { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache, no-transform'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('Access-Control-Allow-Origin', 'http://localhost:5173'); // CORS 설정 필요시 추가 + res.setHeader('Access-Control-Allow-Credentials', 'true'); + res.flushHeaders(); // 즉시 헤더를 전송하여 연결이 끊기지 않도록 함 + } + return next.handle(); + } +} diff --git a/src/modules/notification/docs/notification.docs.ts b/src/modules/notification/docs/notification.docs.ts new file mode 100644 index 0000000..5896d64 --- /dev/null +++ b/src/modules/notification/docs/notification.docs.ts @@ -0,0 +1,68 @@ +import { ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; + +export const getUnReadNotificationsDocs = { + ApiOperation: ApiOperation({ + summary: '읽지 않은 알림 조회', + description: '읽지 않은 알림을 가져옵니다.', + }), + + ApiResponse: ApiResponse({ + status: 200, + description: '읽지 않은 알림 목록', + schema: { + example: { + notifications: [ + { + notificationId: 1, + userId: 10, + senderId: 5, + type: 'comment', + message: 'John님이 회원님의 게시물에 댓글을 남겼습니다.', + isRead: false, + createdAt: '2025-02-02T12:00:00.000Z', + sender: { + nickname: 'John', + profileUrl: 'https://example.com/profile/john.jpg', + }, + }, + { + notificationId: 2, + userId: 10, + senderId: 7, + type: 'like', + message: 'Jane님이 회원님의 게시물을 좋아합니다.', + isRead: false, + createdAt: '2025-02-01T11:00:00.000Z', + sender: { + nickname: 'Jane', + profileUrl: 'https://example.com/profile/jane.jpg', + }, + }, + ], + }, + }, + }), +}; + +export const patchNotificationReadDocs = { + ApiOperation: ApiOperation({ + summary: '알림 읽음 처리', + description: '특정 알림을 읽음 처리합니다.', + }), + ApiParam: ApiParam({ + name: 'notificationId', + description: '읽음 처리할 알림 ID', + type: 'number', + example: 1, + }), + ApiResponse: ApiResponse({ + status: 200, + description: '알림 읽음 처리 완료', + schema: { + example: { + notificationId: 1, // ✅ 이름 변경 + isRead: true, + }, + }, + }), +}; diff --git a/src/modules/notification/notification.controller.ts b/src/modules/notification/notification.controller.ts new file mode 100644 index 0000000..b077c79 --- /dev/null +++ b/src/modules/notification/notification.controller.ts @@ -0,0 +1,110 @@ +import { + Controller, + Sse, + Req, + UseGuards, + UseInterceptors, + Get, + Patch, + Param, + BadRequestException, +} from '@nestjs/common'; +import { Observable, of } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { NotificationsService } from './notification.service'; +import { SseInterceptor } from './Interceptors/notification.interceptor'; +import { ApiBearerAuth } from '@nestjs/swagger'; +import { + getUnReadNotificationsDocs, + patchNotificationReadDocs, +} from './docs/notification.docs'; + +@Controller('notifications') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +export class NotificationsController { + constructor(private readonly notificationsService: NotificationsService) {} + + @Sse('stream') + @UseInterceptors(SseInterceptor) + async streamNotifications(@Req() req): Promise> { + const userId = req.user?.user_id; + + if (!userId) { + console.error('🚨 사용자 인증 정보가 필요합니다.'); + return of({ + event: 'error', + data: { + type: 'error', + message: '사용자 인증 정보가 필요합니다.', + timestamp: new Date().toISOString(), + }, + }); + } + + console.log(`✅ SSE 연결 성공 - 사용자 ${userId}`); + + req.on('close', () => { + console.log(`❌ 사용자 ${userId}와의 SSE 연결 종료`); + }); + + const unreadNotifications = + await this.notificationsService.getUnreadNotifications(userId); + + unreadNotifications.notifications.forEach(notification => { + this.notificationsService.sendRealTimeNotification(userId, { + notificationId: notification.notificationId, // 포함된 notificationId + type: notification.type, + message: notification.message, + senderNickname: notification.sender.nickname, + senderProfileUrl: notification.sender.profileUrl, + }); + }); + + return this.notificationsService.notifications$.asObservable().pipe( + filter(notification => notification.userId === userId), + map(notification => ({ + event: 'message', + data: { + notificationId: notification.notificationId, // 클라이언트에 전달 + type: notification.type, + message: notification.message, + senderNickname: notification.senderNickname, + senderProfileUrl: notification.senderProfileUrl, + timestamp: new Date().toISOString(), + }, + })) + ); + } + + @Get('unread') + @getUnReadNotificationsDocs.ApiOperation + @getUnReadNotificationsDocs.ApiResponse + async getUnreadNotifications(@Req() req) { + const userId = req.user?.user_id; + return this.notificationsService.getUnreadNotifications(userId); + } + + // 특정 알림을 읽음 상태로 변경 + @Patch(':notificationId/delete') + @patchNotificationReadDocs.ApiOperation + @patchNotificationReadDocs.ApiParam + @patchNotificationReadDocs.ApiResponse + async markNotificationAsReadAndDelete( + @Req() req, + @Param('notificationId') notificationId: string + ) { + const userId = Number(req.user?.user_id); + + const numNotificationId = parseInt(notificationId, 10); + if (isNaN(numNotificationId)) { + throw new BadRequestException('유효하지 않은 알림 ID입니다.'); + } + + return this.notificationsService.markNotificationAsRead( + userId, + numNotificationId + ); + } +} diff --git a/src/modules/notification/notification.module.ts b/src/modules/notification/notification.module.ts new file mode 100644 index 0000000..79ae0f2 --- /dev/null +++ b/src/modules/notification/notification.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { NotificationsController } from './notification.controller'; +import { NotificationsService } from '@modules/notification/notification.service'; +import { AuthModule } from '@modules/auth/auth.module'; +import { PrismaModule } from '@src/prisma/prisma.module'; +@Module({ + imports: [AuthModule, PrismaModule], + controllers: [NotificationsController], + providers: [NotificationsService], + exports: [NotificationsService], +}) +export class NotificationModule {} diff --git a/src/modules/notification/notification.service.ts b/src/modules/notification/notification.service.ts new file mode 100644 index 0000000..3f68c75 --- /dev/null +++ b/src/modules/notification/notification.service.ts @@ -0,0 +1,135 @@ +import { Injectable } from '@nestjs/common'; +import { ApiBearerAuth } from '@nestjs/swagger'; +import { PrismaService } from '@src/prisma/prisma.service'; +import { Subject } from 'rxjs'; + +@Injectable() +export class NotificationsService { + public readonly notifications$ = new Subject(); + + constructor(private readonly prisma: PrismaService) {} + + // 알림 생성 + async createNotification( + userId: number, + senderId: number, + type: string, + message: string + ) { + try { + const createdNotification = await this.prisma.notification.create({ + data: { + userId, + senderId, + type, + message, + }, + }); + + console.log('✅ 알림 생성 완료:', createdNotification); + + return { + notificationId: createdNotification.id, // `id`를 `notificationId`로 변경 + ...createdNotification, + }; + } catch (error) { + console.error('알림 생성 중 오류:', error.message); + throw new Error('알림 생성에 실패했습니다.'); + } + } + + async getUnreadNotifications(userId: number) { + console.log(`🔍 [getUnreadNotifications] 시작 - userId: ${userId}`); + + // 1. 읽지 않은 알림 조회 + const unreadNotifications = await this.prisma.notification.findMany({ + where: { + userId: userId, + isRead: false, + }, + include: { + sender: { + select: { + nickname: true, + profile_url: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + console.log( + '📥 [getUnreadNotifications] DB 조회 결과:', + unreadNotifications + ); + + // 2. 데이터를 변환하여 반환 + const transformedNotifications = unreadNotifications.map(notification => { + const transformedNotification = { + notificationId: notification.id, // + userId: notification.userId, + senderId: notification.senderId, + type: notification.type, + message: notification.message, + isRead: notification.isRead, + createdAt: notification.createdAt, + sender: { + nickname: notification.sender.nickname, + profileUrl: notification.sender.profile_url, // `profile_url` -> `profileUrl` + }, + }; + + console.log( + '🔧 [getUnreadNotifications] 변환된 알림:', + transformedNotification + ); + return transformedNotification; + }); + + console.log('📤 [getUnreadNotifications] 최종 반환 데이터:', { + notifications: transformedNotifications, + }); + + return { + notifications: transformedNotifications, + }; + } + + async markNotificationAsRead(userId: number, notificationId: number) { + const notification = await this.prisma.notification.findUnique({ + where: { id: notificationId }, + }); + + if (!notification) { + throw new Error('알림을 찾을 수 없습니다.'); + } + + if (notification.userId !== userId) { + throw new Error('본인의 알림만 처리할 수 있습니다.'); + } + + const updatedNotification = await this.prisma.notification.update({ + where: { id: notificationId }, + data: { isRead: true }, + }); + + return { + notificationId: updatedNotification.id, + isRead: updatedNotification.isRead, + }; + } + + sendRealTimeNotification(userId: number, data: any) { + this.notifications$.next({ + userId, + notificationId: data.notificationId, // 알림 ID 포함 + type: data.type || 'notification', // 이벤트 유형 + message: data.message, // 알림 메시지 + senderNickname: data.senderNickname, // 보낸 사람 닉네임 + senderProfileUrl: data.senderProfileUrl, // 보낸 사람 프로필 URL + timestamp: new Date().toISOString(), // 알림 전송 시간 + }); + } +} diff --git a/src/modules/project/docs/project.docs.ts b/src/modules/project/docs/project.docs.ts new file mode 100644 index 0000000..6e79691 --- /dev/null +++ b/src/modules/project/docs/project.docs.ts @@ -0,0 +1,563 @@ +import { + ApiOperation, + ApiQuery, + ApiResponse, + ApiBody, + ApiParam, + ApiConsumes, +} from '@nestjs/swagger'; + +export const GetProjectsDocs = { + ApiOperation: ApiOperation({ + summary: '프로젝트 목록 조회', + description: + '프로젝트 목록을 페이징으로 조회합니다. 무한 스크롤을 지원합니다.', + }), + ApiQueryCursor: ApiQuery({ + name: 'cursor', + required: false, + type: Number, + description: '마지막 항목의 ID. 무한 스크롤에서 사용.', + example: 10, + }), + ApiQueryRole: ApiQuery({ + name: 'role', + required: false, + type: String, + description: '필터링할 역할 (예: Developer, Designer)', + }), + ApiQueryUnit: ApiQuery({ + name: 'unit', + required: false, + type: String, + description: '필터링할 직업 세부 정보', + }), + ApiQuerySort: ApiQuery({ + name: 'sort', + required: false, + type: Boolean, + description: '정렬 기준 (true: 최신순, false: 인기순)', + example: 'true', + }), + ApiResponseSuccess: ApiResponse({ + status: 200, + description: '프로젝트 목록 조회 성공', + schema: { + example: { + message: { + code: 200, + text: '전체 커넥션허브 조회에 성공했습니다', + }, + projects: [ + { + projectId: 1, + title: 'Project 1', + content: 'Content of project 1', + thumbnailUrl: 'https://example.com/thumbnail1.jpg', + role: 'Developer', + skills: ['JavaScript', 'React'], + detailRoles: ['Frontend Developer'], + hubType: 'Remote', + startDate: '2023-01-01', + duration: '3 months', + workType: 'Full-time', + applyCount: 5, + bookMarkCount: 10, + viewCount: 50, + status: 'OPEN', + createdAt: '2023-01-01T00:00:00Z', + user: { + userId: 1, + nickname: 'testUser', + name: 'Test User', + profileUrl: 'https://example.com/profile.jpg', + role: 'Developer', + }, + }, + ], + pagination: { + lastCursor: 1, + }, + }, + }, + }), +}; + +export const CreateProjectDocs = { + ApiOperation: ApiOperation({ + summary: '프로젝트 생성', + description: '새로운 프로젝트를 생성합니다.', + }), + ApiBody: ApiBody({ + description: '프로젝트 생성 요청 데이터', + schema: { + example: { + title: '프로젝트 제목', + content: '프로젝트 내용', + role: 'Programmer', + hub_type: 'PROJECT', + start_date: '2025-01-01', + duration: '6 months', + work_type: 'ONLINE', + recruiting: true, + skills: ['React', 'TypeScript'], + detail_roles: ['Frontend Developer', 'Fullstack Developer'], + }, + }, + }), + ApiResponseSuccess: ApiResponse({ + status: 201, + description: '프로젝트 생성 성공', + schema: { + example: { + message: { + code: 201, + text: '프로젝트 생성에 성공했습니다', + }, + project: { + projectId: 1, + title: '프로젝트 제목', + content: '프로젝트 내용', + thumbnailUrl: 'thumbNail Photo url', + role: 'Programmer', + hubType: 'PROJECT', + startDate: '2025-01-01', + duration: '6 months', + workType: 'ONLINE', + status: 'OPEN', + viewCount: 0, + applyCount: 0, + bookmarkCount: 0, + createdAt: '2023-01-01T00:00:00Z', + skills: ['React', 'TypeScript'], + detailRoles: ['Frontend Developer', 'Fullstack Developer'], + }, + }, + }, + }), +}; + +export const UpdateProjectDocs = { + ApiOperation: ApiOperation({ + summary: '프로젝트 수정', + description: '특정 프로젝트의 내용을 수정합니다.', + }), + ApiParam: ApiParam({ + name: 'projectId', + description: '수정하려는 프로젝트의 ID', + type: Number, + }), + ApiBody: ApiBody({ + description: '수정할 프로젝트 데이터', + schema: { + example: { + title: '수정된 프로젝트 제목', + content: '수정된 프로젝트 내용', + role: 'Programmer', + hub_type: 'PROJECT', + start_date: '2025-01-01', + duration: '6 months', + work_type: 'ONLINE', + recruiting: true, + skills: ['React', 'TypeScript'], + detail_roles: ['Frontend Developer', 'Fullstack Developer'], + }, + }, + }), + ApiResponse: ApiResponse({ + status: 200, + description: '프로젝트 수정 성공', + schema: { + example: { + message: { + code: 200, + text: '프로젝트가 성공적으로 수정되었습니다.', + }, + project: { + projectId: 1, + title: '수정된 프로젝트 제목', + content: '수정된 프로젝트 내용', + role: 'Programmer', + hubType: 'PROJECT', + thumbnailUrl: 'thumbnail URL', + startDate: '2025-01-23', + duration: '6 months', + workType: 'ONLINE', + skills: ['React', 'TypeScript'], + detailRoles: ['Frontend Developer', 'Fullstack Developer'], + }, + }, + }, + }), +}; + +export const DeleteProjectDocs = { + ApiOperation: ApiOperation({ + summary: '프로젝트 삭제', + description: '특정 프로젝트를 삭제합니다.', + }), + ApiParam: ApiParam({ + name: 'projectId', + description: '삭제하려는 프로젝트의 ID', + type: Number, + }), + ApiResponse: ApiResponse({ + status: 200, + description: '프로젝트 삭제 성공', + schema: { + example: { + message: { code: 200, text: '프로젝트가 삭제되었습니다.' }, + }, + }, + }), +}; + +export const GetPopularProjectsThisWeekDocs = { + ApiOperation: ApiOperation({ + summary: '인기 프로젝트 조회 (이번 주)', + description: '이번 주 가장 인기 있는 프로젝트를 조회합니다.', + }), + ApiResponse: ApiResponse({ + status: 200, + description: '인기 프로젝트 조회 성공', + schema: { + example: { + message: { + code: 200, + text: '인기 프로젝트 조회에 성공했습니다', + }, + popularProjects: [ + { + projectId: 1, + title: '프로젝트 제목', + user: { + userId: 1, + name: 'Lee Chan', + nickname: 'leechan_dev', + profileUrl: 'https://example.com/profile.png', + role: 'Developer', + }, + hubType: 'PROJECT', + }, + { + projectId: 2, + title: '프로젝트 제목 2', + user: { + userId: 2, + name: 'Kim Min', + nickname: 'min_kim', + profileUrl: 'https://example.com/profile2.png', + role: 'Designer', + }, + hubType: 'OUTSOURCING', + }, + ], + }, + }, + }), +}; + +export const UploadFeedImageDocs = { + ApiOperation: ApiOperation({ + summary: '이미지 업로드', + description: '프로젝트 관련 이미지를 업로드합니다.', + }), + ApiConsumes: ApiConsumes('multipart/form-data'), + ApiBody: ApiBody({ + description: '이미지 파일 업로드', + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + }, + }, + }, + }), + ApiResponse: ApiResponse({ + status: 200, + description: '이미지 업로드 성공', + schema: { + example: { + imageUrl: 'https://example.com/uploads/image.png', + message: { + code: 200, + text: '이미지 업로드가 완료되었습니다.', + }, + }, + }, + }), +}; + +export const GetProjectDetailDocs = { + ApiOperation: ApiOperation({ + summary: '프로젝트 상세 조회', + description: '특정 프로젝트의 상세 정보를 조회합니다.', + }), + ApiParam: ApiParam({ + name: 'projectId', + description: '조회하려는 프로젝트의 ID', + type: Number, + }), + ApiResponse: ApiResponse({ + status: 200, + description: '프로젝트 상세 조회 성공', + schema: { + example: { + message: { + code: 200, + text: '프로젝트 상세 조회에 성공했습니다', + }, + project: { + projectId: 1, + title: '프로젝트 제목', + content: '프로젝트 상세 내용', + role: 'Programmer', + hubType: 'PROJECT', + startDate: '2025-01-01T00:00:00Z', + duration: '6 months', + workType: 'ONLINE', + status: 'OPEN', + skills: ['React', 'TypeScript'], + detailRoles: ['Frontend Developer', 'Backend Developer'], + viewCount: 2, + createdAt: '2025-01-01T00:00:00Z', + manager: { + userId: 101, + name: 'Lee Chan', + nickname: 'leechan_dev', + role: 'Programmer', + profileUrl: 'https://example.com/profile.png', + introduce: '프론트엔드 개발자입니다.', + }, + }, + isOwnConnectionHub: true, + }, + }, + }), +}; + +export const ApplyToProjectDocs = { + ApiOperation: ApiOperation({ + summary: '프로젝트 지원', + description: '특정 프로젝트에 사용자가 지원합니다.', + }), + ApiParam: ApiParam({ + name: 'projectId', + description: '지원할 프로젝트의 ID', + type: Number, + }), + ApiResponse: ApiResponse({ + status: 200, + description: '프로젝트 지원 성공', + schema: { + example: { + message: { + text: '프로젝트에 지원되었습니다.', + code: 200, + }, + isApply: true, + }, + }, + }), +}; + +export const GetApplicantsDocs = { + ApiOperation: ApiOperation({ + summary: '지원자 목록 조회', + description: '특정 프로젝트의 지원자 목록을 조회합니다.', + }), + ApiParam: ApiParam({ + name: 'projectId', + description: '지원자를 조회할 프로젝트의 ID', + type: Number, + }), + ApiResponse: ApiResponse({ + status: 200, + description: '지원자 목록 조회 성공', + schema: { + example: { + applicants: [ + { + userId: 1, + name: 'Lee Chan', + nickname: 'leechan_dev', + profileUrl: 'https://example.com/profile.png', + status: 'Pending', + }, + ], + message: { + code: 200, + text: '프로젝트 지원자 목록 조회에 성공했습니다', + }, + }, + }, + }), +}; + +export const CheckApplyStatusDocs = { + ApiOperation: ApiOperation({ + summary: '지원 상태 확인', + description: '사용자가 특정 프로젝트에 지원했는지 확인합니다.', + }), + ApiParam: ApiParam({ + name: 'projectId', + description: '지원 상태를 확인할 프로젝트의 ID', + type: Number, + }), + ApiResponse: ApiResponse({ + status: 200, + description: '지원 상태 확인 성공', + schema: { + example: { + applied: true, + message: '해당 프로젝트에 이미 지원했습니다.', + }, + }, + }), +}; + +export const CancelApplicationDocs = { + ApiOperation: ApiOperation({ + summary: '프로젝트 지원 취소', + description: '사용자가 특정 프로젝트에 대한 지원을 취소합니다.', + }), + ApiParam: ApiParam({ + name: 'projectId', + description: '지원 취소할 프로젝트의 ID', + type: Number, + }), + ApiResponse: ApiResponse({ + status: 200, + description: '프로젝트 지원 취소 성공', + schema: { + example: { + message: '프로젝트 지원이 취소되었습니다.', + }, + }, + }), +}; + +export const UpdateApplicationStatusDocs = { + ApiOperation: ApiOperation({ + summary: '지원 상태 변경', + description: '프로젝트 작성자가 특정 지원자의 지원 상태를 변경합니다.', + }), + ApiParamProject: ApiParam({ + name: 'projectId', + description: '변경할 지원 상태가 속한 프로젝트 ID', + type: Number, + }), + ApiParamApplication: ApiParam({ + name: 'userId', + description: '지원자의 userId', + type: Number, + }), + ApiBody: ApiBody({ + description: '지원 상태 변경 요청 데이터 (Accepted, Rejected, Pending)', + schema: { + example: { + status: 'Accepted', // 또는 'Rejected', 'Pending' + }, + }, + }), + ApiResponse: ApiResponse({ + status: 200, + description: '지원 상태 변경 성공', + schema: { + example: { + message: '지원 상태가 변경되었습니다.', + application: { + applicationId: 10, + status: 'Accepted', + }, + }, + }, + }), +}; + +export const UpdateProjectStatusDocs = { + ApiOperation: ApiOperation({ + summary: '프로젝트 상태 변경', + description: '프로젝트 작성자가 프로젝트 상태를 변경합니다 (OPEN / CLOSE).', + }), + ApiParam: ApiParam({ + name: 'projectId', + description: '상태를 변경할 프로젝트 ID', + type: Number, + }), + ApiBody: ApiBody({ + description: '프로젝트 상태 변경 요청 데이터 true: OPEN, false: CLOSE', + schema: { + example: { + recruiting: true, + }, + }, + }), + ApiResponse: ApiResponse({ + status: 200, + description: '프로젝트 상태 변경 성공', + schema: { + example: { + message: '프로젝트 상태가 변경되었습니다.', + project: { + projectId: 1, + recruiting: true, + status: 'OPEN', + }, + }, + }, + }), +}; + +export const ToggleBookmarkDocs = { + ApiOperation: ApiOperation({ + summary: '북마크 추가/삭제', + description: '특정 프로젝트에 북마크를 추가하거나 삭제합니다.', + }), + ApiParam: ApiParam({ + name: 'projectId', + description: '북마크를 추가하거나 삭제할 프로젝트의 ID', + type: Number, + }), + ApiResponse: ApiResponse({ + status: 200, + description: + '북마크 상태 변경 성공 or 실패 bookmarked: true => 북마크 추가됨 false => 북마크 삭제됨', + schema: { + example: { + message: { + code: 200, + text: '북마크가 추가되었습니다.', // 또는 '북마크가 삭제되었습니다.' + }, + bookmarked: true, // true: 북마크 추가됨, false: 북마크 삭제됨 + }, + }, + }), +}; + +export const CheckBookmarkDocs = { + ApiOperation: ApiOperation({ + summary: '북마크 여부 확인', + description: '특정 프로젝트에 북마크가 되어 있는지 여부를 확인합니다.', + }), + ApiParam: ApiParam({ + name: 'projectId', + description: '북마크 여부를 확인할 프로젝트의 ID', + type: Number, + }), + ApiResponse: ApiResponse({ + status: 200, + description: '북마크 여부 확인 성공 or 실패', + schema: { + example: { + message: { + code: 200, + text: '북마크 여부 확인 성공.', + }, + bookmarked: true, // 또는 false + }, + }, + }), +}; diff --git a/src/modules/project/dto/CreateProject.dto.ts b/src/modules/project/dto/CreateProject.dto.ts new file mode 100644 index 0000000..1f7682f --- /dev/null +++ b/src/modules/project/dto/CreateProject.dto.ts @@ -0,0 +1,50 @@ +import { + IsString, + IsBoolean, + IsDateString, + IsArray, + ArrayNotEmpty, + IsIn, + Length, +} from 'class-validator'; + +export class CreateProjectDto { + @IsString() + @Length(1, 100) + title: string; + + @IsString() + @Length(1, 500) + content: string; + + @IsString() + @IsIn(['Programmer', 'Designer', 'Artist']) + role: string; + + @IsString() + @IsIn(['PROJECT', 'OUTSOURCING']) + hub_type: string; + + @IsDateString() + start_date: string; + + @IsString() + duration: string; + + @IsString() + @IsIn(['ONLINE', 'OFFLINE']) + work_type: string; + + @IsBoolean() + recruiting: boolean; + + @IsArray() + @ArrayNotEmpty() + @IsString({ each: true }) + skills: string[]; + + @IsArray() + @ArrayNotEmpty() + @IsString({ each: true }) + detail_roles: string[]; +} diff --git a/src/modules/project/project.controller.ts b/src/modules/project/project.controller.ts index 66980ae..d31c283 100644 --- a/src/modules/project/project.controller.ts +++ b/src/modules/project/project.controller.ts @@ -1,4 +1,259 @@ -import { Controller } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Get, + Param, + ParseIntPipe, + Patch, + Post, + Put, + Query, + Req, + UploadedFile, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard'; +import { ProjectService } from '@modules/project/project.service'; +import { CreateProjectDto } from './dto/CreateProject.dto'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { + ApplyToProjectDocs, + CancelApplicationDocs, + CheckApplyStatusDocs, + CheckBookmarkDocs, + CreateProjectDocs, + DeleteProjectDocs, + GetApplicantsDocs, + GetPopularProjectsThisWeekDocs, + GetProjectDetailDocs, + GetProjectsDocs, + ToggleBookmarkDocs, + UpdateApplicationStatusDocs, + UpdateProjectDocs, + UpdateProjectStatusDocs, + UploadFeedImageDocs, +} from './docs/project.docs'; +import { ApiBearerAuth } from '@nestjs/swagger'; -@Controller('project') -export class ProjectController {} +@Controller('projects') +export class ProjectController { + constructor(private readonly projectService: ProjectService) {} + + @Get() + @GetProjectsDocs.ApiOperation + @GetProjectsDocs.ApiQueryCursor + @GetProjectsDocs.ApiQueryRole + @GetProjectsDocs.ApiQueryUnit + @GetProjectsDocs.ApiQuerySort + @GetProjectsDocs.ApiResponseSuccess + async getProjects( + @Query('cursor') cursor?: number, + @Query('role') role?: string, + @Query('unit') unit?: string, + @Query('sort') sort: boolean = true + ) { + const limit = 10; + return this.projectService.getProjects({ cursor, limit, role, unit, sort }); + } + + @Post() + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @CreateProjectDocs.ApiOperation + @CreateProjectDocs.ApiBody + @CreateProjectDocs.ApiResponseSuccess + async createProject(@Body() createProjectDto: CreateProjectDto, @Req() req) { + const userId = req.user.user_id; + return this.projectService.createProject(createProjectDto, userId); + } + + @Put(':projectId') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @UpdateProjectDocs.ApiOperation + @UpdateProjectDocs.ApiParam + @UpdateProjectDocs.ApiBody + @UpdateProjectDocs.ApiResponse + async updateProject( + @Param('projectId', ParseIntPipe) projectId: number, + @Body() updateProjectDto: CreateProjectDto, + @Req() req + ) { + const userId = req.user.user_id; // 인증된 사용자 ID + return this.projectService.updateProject( + userId, + projectId, + updateProjectDto + ); + } + + @Delete(':projectId') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @DeleteProjectDocs.ApiOperation + @DeleteProjectDocs.ApiParam + @DeleteProjectDocs.ApiResponse + async deleteProject(@Req() req, @Param('projectId') projectId: string) { + const userId = req.user.user_id; + const numProjectId = parseInt(projectId, 10); + return this.projectService.deleteProject(userId, numProjectId); + } + + @Get('popular-this-week') + @GetPopularProjectsThisWeekDocs.ApiOperation + @GetPopularProjectsThisWeekDocs.ApiResponse + async getPopularProjectsThisWeek() { + return this.projectService.getPopularProjectsThisWeek(); + } + + @Post('image') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @UseInterceptors(FileInterceptor('file')) + @UploadFeedImageDocs.ApiOperation + @UploadFeedImageDocs.ApiConsumes + @UploadFeedImageDocs.ApiBody + @UploadFeedImageDocs.ApiResponse + async func(@Req() req, @UploadedFile() file: Express.Multer.File) { + const userId = req.user.user_id; + console.log(userId); + return await this.projectService.uploadFeedImage(userId, file); + } + + @Get(':projectId') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @GetProjectDetailDocs.ApiOperation + @GetProjectDetailDocs.ApiParam + @GetProjectDetailDocs.ApiResponse + async getProjectDetail(@Req() req, @Param('projectId') projectId: string) { + const userId = req.user.user_id; + const numProjectId = parseInt(projectId, 10); + return this.projectService.getProjectDetail(userId, numProjectId); + } + + @Post(':projectId/apply') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApplyToProjectDocs.ApiOperation + @ApplyToProjectDocs.ApiParam + @ApplyToProjectDocs.ApiResponse + async applyToProject( + @Param('projectId', ParseIntPipe) projectId: number, + @Req() req + ) { + const userId = req.user.user_id; // 인증된 사용자 ID + return this.projectService.applyToProject(userId, projectId); + } + + @Get(':projectId/applicants') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @GetApplicantsDocs.ApiOperation + @GetApplicantsDocs.ApiParam + @GetApplicantsDocs.ApiResponse + async getApplicants(@Param('projectId', ParseIntPipe) projectId: number) { + return this.projectService.getApplicants(projectId); + } + + @Get(':projectId/apply-status') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @CheckApplyStatusDocs.ApiOperation + @CheckApplyStatusDocs.ApiParam + @CheckApplyStatusDocs.ApiResponse + async checkApplyStatus( + @Param('projectId', ParseIntPipe) projectId: number, + @Req() req + ) { + const userId = req.user.user_id; // 인증된 사용자 ID + return this.projectService.checkApplyStatus(userId, projectId); + } + + @Delete(':projectId/cancel-apply') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @CancelApplicationDocs.ApiOperation + @CancelApplicationDocs.ApiParam + @CancelApplicationDocs.ApiResponse + async cancelApplication( + @Param('projectId', ParseIntPipe) projectId: number, + @Req() req + ) { + const userId = req.user.user_id; // 인증된 사용자 ID + return this.projectService.cancelApplication(userId, projectId); + } + + @Patch(':projectId/applications/:userId/status') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @UpdateApplicationStatusDocs.ApiOperation + @UpdateApplicationStatusDocs.ApiParamProject + @UpdateApplicationStatusDocs.ApiParamApplication + @UpdateApplicationStatusDocs.ApiBody + @UpdateApplicationStatusDocs.ApiResponse + async updateApplicationStatus( + @Param('projectId', ParseIntPipe) projectId: number, + @Param('userId', ParseIntPipe) targetUserId: number, // 지원자의 userId를 받음 + @Body('status') status: 'Accepted' | 'Rejected' | 'Pending', + @Req() req + ) { + const userId = req.user.user_id; // 인증된 사용자 ID + return this.projectService.updateApplicationStatus( + userId, + projectId, + targetUserId, + status + ); + } + + @Patch(':projectId/status') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @UpdateProjectStatusDocs.ApiOperation + @UpdateProjectStatusDocs.ApiParam + @UpdateProjectStatusDocs.ApiBody + @UpdateProjectStatusDocs.ApiResponse + async updateProjectStatus( + @Param('projectId', ParseIntPipe) projectId: number, + @Body('recruiting') recruiting: boolean, + @Req() req + ) { + const userId = req.user.user_id; // 인증된 사용자 ID + return this.projectService.updateProjectStatus( + userId, + projectId, + recruiting + ); + } + + @Post(':projectId/bookmark') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ToggleBookmarkDocs.ApiOperation + @ToggleBookmarkDocs.ApiParam + @ToggleBookmarkDocs.ApiResponse + async toggleBookmark( + @Param('projectId', ParseIntPipe) projectId: number, + @Req() req + ) { + const userId = req.user.user_id; // 인증된 사용자 ID + return this.projectService.toggleBookmark(userId, projectId); + } + + @Get(':projectId/bookmark') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @CheckBookmarkDocs.ApiOperation + @CheckBookmarkDocs.ApiParam + @CheckBookmarkDocs.ApiResponse + async checkBookmark( + @Param('projectId', ParseIntPipe) projectId: number, + @Req() req + ) { + const userId = req.user.user_id; // 인증된 사용자 ID + return this.projectService.checkBookmark(userId, projectId); + } +} diff --git a/src/modules/project/project.module.ts b/src/modules/project/project.module.ts index 4e9329e..6af336b 100644 --- a/src/modules/project/project.module.ts +++ b/src/modules/project/project.module.ts @@ -1,9 +1,15 @@ import { Module } from '@nestjs/common'; import { ProjectController } from './project.controller'; import { ProjectService } from './project.service'; +import { AuthModule } from '@modules/auth/auth.module'; +import { PrismaService } from '@prisma/prisma.service'; +import { S3Module } from '@src/s3/s3.module'; +import { NotificationModule } from '../notification/notification.module'; @Module({ + imports: [AuthModule, S3Module, NotificationModule], controllers: [ProjectController], - providers: [ProjectService], + providers: [ProjectService, PrismaService], + exports: [ProjectService], }) export class ProjectModule {} diff --git a/src/modules/project/project.service.ts b/src/modules/project/project.service.ts index 3274dd0..88c0410 100644 --- a/src/modules/project/project.service.ts +++ b/src/modules/project/project.service.ts @@ -1,4 +1,908 @@ -import { Injectable } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + ForbiddenException, + HttpException, + HttpStatus, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { PrismaService } from '@prisma/prisma.service'; +import { CreateProjectDto } from './dto/CreateProject.dto'; +import { startOfWeek, endOfWeek } from 'date-fns'; +import { S3Service } from '@src/s3/s3.service'; +import * as cheerio from 'cheerio'; +import { NotificationsService } from '../notification/notification.service'; @Injectable() -export class ProjectService {} +export class ProjectService { + constructor( + private readonly prisma: PrismaService, + private readonly s3: S3Service, + private readonly notificationsService: NotificationsService + ) {} + + async getProjects(params: { + cursor?: number; + limit: number; + role?: string; + unit?: string; + sort: boolean; + }) { + const { cursor, limit, role, unit, sort } = params; + + const where: any = {}; + if (role) where.role = role; + // Details 테이블에서 detailJobs 조건 추가 + if (unit) { + where.Details = { + some: { + detail_role: { + name: unit, // unit 값을 detail_role.name과 비교 + }, + }, + }; + } + + // 커서 조건 추가 + if (cursor) { + const validCursor = await this.prisma.projectPost.findUnique({ + where: { id: cursor }, + }); + + if (!validCursor) { + throw new BadRequestException('유효하지 않은 커서 값입니다.'); + } + + // 정렬 조건에 따라 커서 조건 추가 + if (sort) { + where.created_at = { lt: validCursor.created_at }; // created_at 기준 + } else { + where.saved_count = { lt: validCursor.saved_count }; // saved_count 기준 + } + } + + const orderBy: any[] = []; + orderBy.push(sort ? { created_at: 'desc' } : { saved_count: 'desc' }); + + const projects = await this.prisma.projectPost.findMany({ + take: limit, + where, + orderBy, + include: { + Tags: { select: { tag: { select: { name: true } } } }, + Applications: { select: { id: true } }, + Details: { select: { detail_role: { select: { name: true } } } }, + user: { + select: { + id: true, + name: true, + nickname: true, + profile_url: true, + introduce: true, + role: { select: { name: true } }, + }, + }, + }, + }); + + const formattedProjects = projects.map(project => ({ + projectId: project.id, + title: project.title, + content: project.content, + thumbnailUrl: project.thumbnail_url, + role: project.role, + skills: project.Tags.map(tag => `${tag.tag.name}`), + detailRoles: project.Details.map(d => `${d.detail_role.name}`), + hubType: project.hub_type, + startDate: project.start_date.toISOString().split('T')[0], + duration: project.duration, + workType: project.work_type, + applyCount: project.Applications.length, + bookMarkCount: project.saved_count, + viewCount: project.view, + status: project.recruiting ? 'OPEN' : 'CLOSED', + createdAt: project.created_at, + user: { + userId: project.user.id, + nickname: project.user.nickname, + name: project.user.name, + profileUrl: project.user.profile_url, + role: project.user.role.name, + }, + })); + + const lastCursor = projects[projects.length - 1]?.id || null; + + return { + message: { + code: 200, + text: + projects.length > 0 + ? '프로젝트 조회에 성공했습니다.' + : '더 이상 프로젝트가 없습니다.', + }, + projects: formattedProjects, + pagination: { + lastCursor, + }, + }; + } + + async createProject(createProjectDto: CreateProjectDto, userId: number) { + const { + title, + content, + role, + hub_type, + start_date, + duration, + work_type, + recruiting, + skills, + detail_roles, + } = createProjectDto; + + const thumbnailUrl = await this.getThumbnailUrl(content); + // 프로젝트 생성 + const project = await this.prisma.projectPost.create({ + data: { + title, + content, + role, + hub_type, + start_date: new Date(start_date), + duration, + work_type, + recruiting, + thumbnail_url: thumbnailUrl, + user_id: userId, // userId를 사용하여 사용자 식별 + }, + }); + + // 태그 저장 (skills) + const tags = []; + for (const skill of skills) { + const tag = await this.prisma.projectTag.upsert({ + where: { name: skill }, + create: { name: skill }, + update: {}, + }); + + await this.prisma.projectPostTag.create({ + data: { + post_id: project.id, + tag_id: tag.id, + }, + }); + + tags.push(tag.name); // 생성된 태그 추가 + } + + const roleMapping: Record = { + Programmer: 1, + Artist: 2, + Designer: 3, + }; + + // 매핑된 role_id 가져오기 + const saveRoleId = roleMapping[role]; + + // 모집단위 저장 (detail_roles) + const roles = []; + for (const detail_role of detail_roles) { + const role = await this.prisma.detailRole.upsert({ + where: { name: detail_role }, + create: { name: detail_role, role_id: saveRoleId }, + update: {}, + }); + + await this.prisma.projectDetailRole.create({ + data: { + post_id: project.id, + detail_role_id: role.id, + }, + }); + + roles.push(role.name); // 생성된 모집단위 추가 + } + + // 결과 반환 + return { + message: { + code: 201, + text: '프로젝트 생성에 성공했습니다', + }, + project: { + projectId: project.id, + title: project.title, + content: project.content, + thumbnailUrl: project.thumbnail_url, + role: project.role, + hubType: project.hub_type, + startDate: project.start_date, + duration: project.duration, + workType: project.work_type, + status: project.recruiting ? 'OPEN' : 'CLOSED', + viewCount: project.view, + applyCount: 0, + bookmarkCount: 0, + createdAt: project.created_at, + skills: tags, + detailRoles: roles, + }, + }; + } + + async getPopularProjectsThisWeek() { + const today = new Date(); + const startDate = startOfWeek(today, { weekStartsOn: 1 }); // 월요일 시작 + const endDate = endOfWeek(today, { weekStartsOn: 1 }); // 일요일 종료 + + // 이번 주 북마크가 많은 프로젝트를 조회 + const popularProjects = await this.prisma.projectPost.findMany({ + where: { + Saves: { + some: { + created_at: { + gte: startDate, + lte: endDate, + }, + }, + }, + }, + include: { + user: { + select: { + id: true, + name: true, + nickname: true, + profile_url: true, + role: true, + }, + }, + }, + orderBy: { + saved_count: 'desc', // 북마크 수 기준 정렬 + }, + take: 5, // 상위 5개 + }); + + const results = popularProjects.map(project => ({ + projectId: project.id, + title: project.title, + user: { + userId: project.user.id, + name: project.user.name, + nickname: project.user.nickname, + profileUrl: project.user.profile_url, + role: project.user.role.name, + }, + hubType: project.hub_type, + })); + // 반환 데이터 가공 + return { + message: { + code: 200, + text: '인기 프로젝트 조회에 성공했습니다', + }, + popularProjects: results, + }; + } + + async uploadFeedImage(userId: number, file: Express.Multer.File) { + const fileType = file.mimetype.split('/')[1]; + const imageUrl = await this.s3.uploadImage( + 8, + file.buffer, + fileType, + 'pad_projects/image' + ); + + return { + imageUrl, + message: { code: 200, text: '이미지 업로드가 완료되었습니다.' }, + }; + } + + // 썸네일 추출 + async getThumbnailUrl(text: string) { + try { + const $ = cheerio.load(text); + const thumnailUrl = $('img').first().attr('src'); + return thumnailUrl; + } catch (err) { + throw err; + } + } + + async getProjectDetail(userId: number, numProjectId: number) { + // 조회수 증가 + await this.prisma.projectPost.update({ + where: { id: numProjectId }, + data: { + view: { + increment: 1, // view 값을 1 증가 + }, + }, + }); + + // 프로젝트 상세 정보 조회 + const project = await this.prisma.projectPost.findUnique({ + where: { id: numProjectId }, + include: { + Tags: { + select: { + tag: { + select: { name: true }, + }, + }, + }, + Details: { + select: { + detail_role: { + select: { name: true }, + }, + }, + }, + user: { + select: { + id: true, + name: true, + nickname: true, + profile_url: true, + introduce: true, + role: true, + }, + }, + Applications: { + select: { id: true }, // 지원 데이터를 가져옴 + }, + }, + }); + + if (!project) { + throw new NotFoundException('프로젝트를 찾을 수 없습니다.'); + } + + // 사용자가 작성자인지 여부 확인 + const isOwnConnectionHub = project.user.id === userId; + + // 데이터 반환 + return { + message: { + code: 200, + text: '프로젝트 상세 조회에 성공했습니다', + }, + project: { + projectId: project.id, + title: project.title, + content: project.content, + role: project.role, + hubType: project.hub_type, + startDate: project.start_date, + duration: project.duration, + workType: project.work_type, + status: project.recruiting ? 'OPEN' : 'CLOSED', + skills: project.Tags.map(t => t.tag.name), + detailRoles: project.Details.map(d => d.detail_role.name), + viewCount: project.view, // 이미 증가된 view 값을 사용 + bookmarkCount: project.saved_count, + applyCount: project.Applications.length, + createdAt: project.created_at, + manager: { + userId: project.user.id, + name: project.user.name, + nickname: project.user.nickname, + role: project.user.role.name, + profileUrl: project.user.profile_url, + introduce: project.user.introduce ? project.user.introduce : null, + }, + }, + isOwnConnectionHub, + }; + } + + async applyToProject(userId: number, projectId: number) { + // 프로젝트 존재 여부 확인 + const project = await this.prisma.projectPost.findUnique({ + where: { id: projectId }, + select: { user_id: true }, // 프로젝트 작성자 ID 가져오기 + }); + + if (!project) { + throw new NotFoundException('프로젝트를 찾을 수 없습니다.'); + } + + // 이미 지원했는지 확인 + const existingApplication = await this.prisma.userApplyProject.findUnique({ + where: { + user_id_post_id: { user_id: userId, post_id: projectId }, + }, + }); + + if (existingApplication) { + throw new ConflictException('이미 지원한 프로젝트입니다.'); + } + + // 지원 생성 + await this.prisma.userApplyProject.create({ + data: { + user_id: userId, + post_id: projectId, + }, + }); + + // 지원 알림 전송 + const sender = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { nickname: true, profile_url: true }, + }); + + const message = `${sender.nickname}님이 회원님의 프로젝트에 지원했습니다.`; + // 알림 생성 및 `notificationId` 반환 + const createdNotification = + await this.notificationsService.createNotification( + project.user_id, // 프로젝트 작성자 ID + userId, // 지원자 ID + 'application', + message + ); + + // 실시간 알림 전송 (notificationId 포함) + this.notificationsService.sendRealTimeNotification(project.user_id, { + notificationId: createdNotification.notificationId, // 알림 ID 추가 + type: 'application', + message, + senderNickname: sender.nickname, + senderProfileUrl: sender.profile_url, + }); + + return { + message: { + text: '프로젝트에 지원되었습니다.', + code: 200, + }, + isApply: true, + }; + } + + async getApplicants(projectId: number) { + // 프로젝트 존재 여부 확인 + const project = await this.prisma.projectPost.findUnique({ + where: { id: projectId }, + }); + + if (!project) { + throw new NotFoundException('프로젝트를 찾을 수 없습니다.'); + } + + // 지원자 목록 조회 + const applicants = await this.prisma.userApplyProject.findMany({ + where: { post_id: projectId }, + select: { + user: { + select: { + id: true, + name: true, + nickname: true, + profile_url: true, + introduce: true, + }, + }, + status: true, + }, + }); + const resultapplicants = applicants.map(applicant => ({ + userId: applicant.user.id, + name: applicant.user.name, + nickname: applicant.user.nickname, + profileUrl: applicant.user.profile_url, + status: applicant.status, + })); + return { + applicants: resultapplicants, + message: { + code: 200, + text: '프로젝트 지원자 목록 조회에 성공했습니다', + }, + }; + } + + async updateProject( + userId: number, + projectId: number, + updateProjectDto: CreateProjectDto + ) { + const { + title, + content, + role, + hub_type, + start_date, + duration, + work_type, + recruiting, + skills, + detail_roles, + } = updateProjectDto; + + // 권한 확인 + const auth = await this.feedAuth(userId, projectId); + if (!auth) { + throw new HttpException('권한이 없습니다.', HttpStatus.FORBIDDEN); + } + + // 썸네일 URL 업데이트 + const thumbnailUrl = await this.getThumbnailUrl(content); + + // 프로젝트 업데이트 + await this.prisma.projectPost.update({ + where: { id: projectId }, + data: { + title, + content, + role, + hub_type, + start_date: new Date(start_date), + duration, + work_type, + recruiting, + thumbnail_url: thumbnailUrl, + }, + }); + + // 모집단위 업데이트 + const roleMapping: Record = { + Programmer: 1, + Artist: 2, + Designer: 3, + }; + + const saveRoleId = roleMapping[role]; + + await this.prisma.$transaction(async prisma => { + // 기존 태그 삭제 + await prisma.projectPostTag.deleteMany({ + where: { post_id: projectId }, + }); + + // 새 태그 추가 + for (const skill of skills) { + const tag = await prisma.projectTag.upsert({ + where: { name: skill }, + create: { name: skill }, + update: {}, + }); + + await prisma.projectPostTag.create({ + data: { + post_id: projectId, + tag_id: tag.id, + }, + }); + } + + // 기존 모집단위 삭제 + await prisma.projectDetailRole.deleteMany({ + where: { post_id: projectId }, + }); + + // 새 모집단위 추가 + for (const detail_role of detail_roles) { + const role = await prisma.detailRole.upsert({ + where: { name: detail_role }, + create: { name: detail_role, role_id: saveRoleId }, + update: {}, + }); + + await prisma.projectDetailRole.create({ + data: { + post_id: projectId, + detail_role_id: role.id, + }, + }); + } + }); + + // 결과 반환 + const updatedProject = await this.prisma.projectPost.findUnique({ + where: { id: projectId }, + include: { + Tags: { + select: { + tag: { select: { name: true } }, + }, + }, + Details: { + select: { + detail_role: { select: { name: true } }, + }, + }, + }, + }); + + if (!updatedProject) { + throw new NotFoundException('프로젝트를 찾을 수 없습니다.'); + } + + return { + message: { code: 200, text: '프로젝트가 성공적으로 수정되었습니다.' }, + project: { + projectId: updatedProject.id, + title: updatedProject.title, + content: updatedProject.content, + role: updatedProject.role, + hubType: updatedProject.hub_type, + thumbnailUrl: updatedProject.thumbnail_url, + startDate: updatedProject.start_date, + duration: updatedProject.duration, + workType: updatedProject.work_type, + skills: updatedProject.Tags.map(t => t.tag.name), + detailRoles: updatedProject.Details.map(d => d.detail_role.name), + }, + }; + } + + // 게시글 권한 확인 + async feedAuth(userId: number, projectId: number): Promise { + const auth = await this.prisma.projectPost.findUnique({ + where: { id: projectId }, + select: { user_id: true }, + }); + return auth.user_id === userId; + } + + async checkApplyStatus(userId: number, projectId: number) { + const application = await this.prisma.userApplyProject.findUnique({ + where: { + user_id_post_id: { user_id: userId, post_id: projectId }, + }, + select: { + created_at: true, + }, + }); + + if (!application) { + return { status: 'not_applied' }; + } + + return { + status: 'applied', + applied_at: application.created_at, + }; + } + + async cancelApplication(userId: number, projectId: number) { + const application = await this.prisma.userApplyProject.findUnique({ + where: { + user_id_post_id: { user_id: userId, post_id: projectId }, + }, + }); + + if (!application) { + throw new NotFoundException('해당 프로젝트에 지원한 기록이 없습니다.'); + } + + await this.prisma.userApplyProject.delete({ + where: { + user_id_post_id: { user_id: userId, post_id: projectId }, + }, + }); + + return { + message: { + code: 200, + text: '프로젝트 지원이 취소되었습니다.', + }, + }; + } + + async updateApplicationStatus( + userId: number, + projectId: number, + targetUserId: number, // 지원자의 userId + status: 'Accepted' | 'Rejected' | 'Pending' + ) { + // 프로젝트 작성자인지 확인 + const project = await this.prisma.projectPost.findUnique({ + where: { id: projectId }, + select: { user_id: true }, + }); + + if (!project) { + throw new NotFoundException('프로젝트를 찾을 수 없습니다.'); + } + + if (project.user_id !== userId) { + throw new ForbiddenException('해당 프로젝트의 작성자가 아닙니다.'); + } + + // 지원 정보 가져오기 + const application = await this.prisma.userApplyProject.findUnique({ + where: { + user_id_post_id: { user_id: targetUserId, post_id: projectId }, + }, + }); + + if (!application) { + throw new NotFoundException( + '해당 사용자가 프로젝트에 지원한 기록이 없습니다.' + ); + } + + // 지원 상태 업데이트 + const updatedApplication = await this.prisma.userApplyProject.update({ + where: { id: application.id }, + data: { status }, + }); + + // 지원 상태 변경 알림 전송 + const sender = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { nickname: true, profile_url: true }, + }); + + const message = `${sender.nickname}님이 회원님의 프로젝트 지원 상태를 '${status}'로 변경했습니다.`; + + // 알림 생성 및 notificationId 받기 + const createdNotification = + await this.notificationsService.createNotification( + targetUserId, // 지원자 ID + userId, // 프로젝트 작성자 ID + 'applicationStatus', + message + ); + + // 실시간 알림 전송 (notificationId 포함) + this.notificationsService.sendRealTimeNotification(targetUserId, { + notificationId: createdNotification.notificationId, // 알림 ID 추가 + type: 'applicationStatus', + message, + senderNickname: sender.nickname, + senderProfileUrl: sender.profile_url, + }); + + return { + message: '지원 상태가 변경되었습니다.', + application: { + applicationId: updatedApplication.id, + status: updatedApplication.status, + }, + }; + } + + async updateProjectStatus( + userId: number, + projectId: number, + recruiting: boolean + ) { + // 프로젝트 작성자인지 확인 + const project = await this.prisma.projectPost.findUnique({ + where: { id: projectId }, + select: { user_id: true }, + }); + + if (!project) { + throw new NotFoundException('프로젝트를 찾을 수 없습니다.'); + } + + if (project.user_id !== userId) { + throw new ForbiddenException('해당 프로젝트의 작성자가 아닙니다.'); + } + + // 프로젝트 상태 업데이트 + const updatedProject = await this.prisma.projectPost.update({ + where: { id: projectId }, + data: { recruiting }, + }); + + return { + message: '프로젝트 상태가 변경되었습니다.', + project: { + projectId: updatedProject.id, + recruiting: updatedProject.recruiting, + status: recruiting ? 'OPEN' : 'CLOSED', + }, + }; + } + + async deleteProject(userId: number, projectId: number) { + const auth = await this.feedAuth(userId, projectId); + if (!auth) { + throw new HttpException('권한이 없습니다.', HttpStatus.FORBIDDEN); + } + await this.prisma.$transaction([ + this.prisma.projectDetailRole.deleteMany({ + where: { post_id: projectId }, + }), + + this.prisma.projectPostTag.deleteMany({ + where: { post_id: projectId }, + }), + + this.prisma.projectSave.deleteMany({ + where: { post_id: projectId }, + }), + + this.prisma.projectPost.delete({ + where: { id: projectId }, + }), + ]); + + return { message: { code: 200, text: '프로젝트가 삭제되었습니다.' } }; + } + + async toggleBookmark(userId: number, projectId: number) { + // 북마크 존재 여부 확인 + const existingBookmark = await this.prisma.projectSave.findFirst({ + where: { user_id: userId, post_id: projectId }, + }); + + if (existingBookmark) { + // 북마크 삭제 + await this.prisma.projectSave.delete({ + where: { id: existingBookmark.id }, + }); + + // saved_count 감소 + await this.prisma.projectPost.update({ + where: { id: projectId }, + data: { + saved_count: { + decrement: 1, // saved_count 감소 + }, + }, + }); + + return { + message: { + code: 200, + text: '북마크가 삭제되었습니다.', + }, + bookmarked: false, + }; + } + + // 북마크 추가 + await this.prisma.projectSave.create({ + data: { + user_id: userId, + post_id: projectId, + }, + }); + + // saved_count 증가 + await this.prisma.projectPost.update({ + where: { id: projectId }, + data: { + saved_count: { + increment: 1, // saved_count 증가 + }, + }, + }); + + return { + message: { + code: 200, + text: '북마크가 추가되었습니다.', + }, + bookmarked: true, + }; + } + + async checkBookmark(userId: number, projectId: number) { + // 북마크 여부 확인 + const bookmark = await this.prisma.projectSave.findFirst({ + where: { user_id: userId, post_id: projectId }, + }); + + return { + message: { + code: 200, + text: '북마크 여부 확인 성공.', + }, + bookmarked: !!bookmark, + }; + } +} diff --git a/src/modules/redis/redis.service.ts b/src/modules/redis/redis.service.ts index 1a122ef..657049b 100644 --- a/src/modules/redis/redis.service.ts +++ b/src/modules/redis/redis.service.ts @@ -21,5 +21,4 @@ export class RedisService { const result = await this.redis.exists(key); return result === 1; } - } diff --git a/src/modules/user/docs/user.docs.ts b/src/modules/user/docs/user.docs.ts new file mode 100644 index 0000000..abe6b27 --- /dev/null +++ b/src/modules/user/docs/user.docs.ts @@ -0,0 +1,1072 @@ +import { + ApiOperation, + ApiParam, + ApiResponse, + ApiBody, + ApiConsumes, + ApiQuery, +} from '@nestjs/swagger'; + +export const GetUserFollowersDocs = { + ApiOperation: ApiOperation({ + summary: '사용자를 팔로우하는 사용자 목록 조회', + description: '특정 사용자를 팔로우하는 사용자들의 목록을 반환합니다.', + }), + ApiResponse: ApiResponse({ + status: 200, + description: '팔로워 목록 조회 성공', + schema: { + example: { + message: { + code: 200, + text: '팔로워 목록 조회에 성공했습니다', + }, + followerUsers: [ + { + id: 1, + nickname: 'Alice', + profileUrl: 'https://example.com/profiles/alice.jpg', + }, + ], + }, + }, + }), +}; + +export const GetUserFollowingsDocs = { + ApiOperation: ApiOperation({ + summary: '사용자가 팔로우한 사용자 목록 조회', + description: '특정 사용자가 팔로우한 사용자들의 목록을 반환합니다.', + }), + ApiResponse: ApiResponse({ + status: 200, + description: '팔로잉 목록 조회 성공', + schema: { + example: { + message: { + code: 200, + text: '팔로잉 목록 조회에 성공했습니다', + }, + followingUsers: [ + { + id: 3, + nickname: 'Charlie', + profileUrl: 'https://example.com/profiles/charlie.jpg', + }, + ], + }, + }, + }), +}; + +export const GetUserProfileDocs = { + ApiOperation: ApiOperation({ + summary: '사용자 프로필 정보 조회', + description: '특정 사용자의 프로필 정보를 반환합니다.', + }), + ApiParam: ApiParam({ + name: 'userId', + required: true, + description: '조회할 사용자의 ID', + type: 'string', + }), + ApiResponse: ApiResponse({ + status: 200, + description: '사용자 프로필 조회 성공', + schema: { + example: { + message: { + code: 200, + text: '유저 프로필 조회에 성공했습니다', + }, + status: '둘러보는 중', + githubUsername: 'JohnGit', + works: [ + { + title: 'My Project', + myPageProjectId: 1, + projectProfileUrl: 'projectProfileUrl', + description: 'Project description here.', + links: [ + { + type: 'Github', + url: 'https://github.com/johndoe/myproject', + }, + ], + }, + { + title: 'My Project2', + myPageProjectId: 2, + projectProfileUrl: 'projectProfileUrl2', + description: 'Project description here2.', + links: [ + { + type: 'Github', + url: 'https://github.com/johndoe/myproject2', + }, + ], + }, + ], + followerCount: 12, + followingCount: 34, + feedCount: 12, + applyCount: 17, + isOwnProfile: true, + }, + }, + }), +}; + +export const GetUserProfileHeaderDocs = { + ApiOperation: ApiOperation({ + summary: '유저 프로필 헤더 조회', + description: '닉네임을 기반으로 특정 유저의 프로필 헤더를 조회합니다.', + }), + ApiParam: ApiParam({ + name: 'nickname', + required: true, + description: '조회할 유저의 닉네임', + example: 'testNickname', + }), + ApiResponse: ApiResponse({ + status: 200, + description: '프로필 헤더 조회 성공', + schema: { + example: { + message: { + code: 200, + text: '유저 프로필(헤더 부분) 조회에 성공했습니다', + }, + userId: 1, + nickname: 'testNickname', + profileUrl: 'https://example.com/profile.jpg', + role: 'Developer', + introduce: '안녕하세요. 저는 개발자입니다.', + userLinks: ['https://github.com/test', 'https://test.com'], + isOwnProfile: false, + isFollowing: true, + }, + }, + }), +}; + +export const AddProjectDocs = { + ApiOperation: ApiOperation({ + summary: '프로젝트 추가', + description: + '사용자의 마이페이지에 새 프로젝트를 추가합니다. 프로젝트 이미지 업로드가 가능합니다.', + }), + ApiConsumes: ApiConsumes('multipart/form-data'), + ApiBody: ApiBody({ + description: + '추가할 프로젝트 데이터. `typeId` : 1 = Github, 2 = Web, 3 = IOS, 4 = Android', + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + description: '프로젝트 프로필 이미지 파일', + }, + title: { + type: 'string', + description: '프로젝트 제목', + }, + description: { + type: 'string', + description: '프로젝트 설명', + }, + links: { + type: 'string', + description: '프로젝트 링크 배열 (JSON 문자열)', + example: JSON.stringify([ + { url: 'https://github.com/myproject', typeId: 1 }, + { url: 'https://myproject.com', typeId: 2 }, + ]), + }, + }, + required: ['title', 'description', 'links'], + }, + }), + ApiResponse: ApiResponse({ + status: 201, + description: '프로젝트 추가 성공', + schema: { + example: { + message: { + code: 201, + text: '마이페이지에 프로젝트 추가에 성공했습니다', + }, + myPageProjectId: 1, + title: 'My Project', + description: 'This is a description of my project.', + projectProfileUrl: 'https://s3.example.com/path/to/image.png', + links: [ + { url: 'https://github.com/myproject', type: 'Github' }, + { url: 'https://myproject.com', type: 'Web' }, + ], + }, + }, + }), +}; + +export const UpdateProjectDocs = { + ApiOperation: ApiOperation({ + summary: '프로젝트 수정', + description: + '사용자의 특정 프로젝트를 수정합니다. 프로젝트 이미지도 수정 가능합니다.', + }), + ApiParam: ApiParam({ + name: 'projectId', + required: true, + description: '수정할 프로젝트의 ID', + type: 'string', + }), + ApiConsumes: ApiConsumes('multipart/form-data'), + ApiBody: ApiBody({ + description: '수정할 프로젝트 데이터', + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + description: '새로운 프로젝트 프로필 이미지 파일', + }, + title: { + type: 'string', + description: '수정된 프로젝트 제목', + }, + description: { + type: 'string', + description: '수정된 프로젝트 설명', + }, + links: { + type: 'string', + description: '수정된 프로젝트 링크 배열 (JSON 문자열)', + example: JSON.stringify([ + { url: 'https://github.com/updatedproject', typeId: 1 }, + { url: 'https://updatedproject.com', typeId: 2 }, + ]), + }, + }, + required: ['title', 'description', 'links'], + }, + }), + ApiResponse: ApiResponse({ + status: 200, + description: '프로젝트 수정 성공', + schema: { + example: { + message: { + code: 200, + text: '마이페이지에 프로젝트 수정에 성공했습니다', + }, + myPageProjectId: 1, + title: 'Updated Project', + description: 'This is an updated description.', + projectProfileUrl: 'https://s3.example.com/path/to/updated_image.png', + links: [ + { url: 'https://github.com/updatedproject', type: 'Github' }, + { url: 'https://updatedproject.com', type: 'Website' }, + ], + }, + }, + }), +}; + +export const DeleteProjectDocs = { + ApiOperation: ApiOperation({ + summary: '프로젝트 삭제', + description: '사용자의 특정 프로젝트를 삭제합니다.', + }), + ApiParam: ApiParam({ + name: 'projectId', + required: true, + description: '삭제할 프로젝트의 ID', + type: 'string', + }), + ApiResponse: ApiResponse({ + status: 200, + description: '프로젝트 삭제 성공', + schema: { + example: { + message: { + code: 200, + text: '프로젝트 삭제에 성공했습니다', + }, + projectId: 1, + }, + }, + }), +}; + +export const AddWorkDocs = { + ApiOperation: ApiOperation({ + summary: '아티스트 작업물 추가', + description: '아티스트의 새 작업물(musicUrl)을 추가합니다.', + }), + ApiBody: ApiBody({ + description: '추가할 작업물의 musicUrl', + schema: { + example: { + musicUrl: 'https://www.youtube.com/watch?v=example', + }, + }, + }), + ApiResponse: ApiResponse({ + status: 201, + description: '작업물 추가 성공', + schema: { + example: { + message: { + code: 201, + text: '작업물이 성공적으로 추가되었습니다.', + }, + musicId: 1, + musicUrl: 'https://www.youtube.com/watch?v=example', + }, + }, + }), +}; + +export const UpdateWorkDocs = { + ApiOperation: ApiOperation({ + summary: '아티스트 작업물 수정', + description: '특정 작업물의 musicUrl을 수정합니다.', + }), + ApiParam: ApiParam({ + name: 'workId', + required: true, + description: '수정할 작업물의 ID', + type: 'string', + }), + ApiBody: ApiBody({ + description: '수정할 작업물의 musicUrl', + schema: { + example: { + musicUrl: 'https://www.youtube.com/watch?v=updated_example', + }, + }, + }), + ApiResponse: ApiResponse({ + status: 200, + description: '작업물 수정 성공', + schema: { + example: { + message: { + code: 200, + text: '작업물이 성공적으로 수정되었습니다.', + }, + musicId: 1, + musicUrl: 'https://www.youtube.com/watch?v=updated_example', + }, + }, + }), +}; + +export const DeleteWorkDocs = { + ApiOperation: ApiOperation({ + summary: '아티스트 작업물 삭제', + description: '특정 작업물을 삭제합니다.', + }), + ApiParam: ApiParam({ + name: 'workId', + required: true, + description: '삭제할 작업물의 ID', + type: 'string', + }), + ApiResponse: ApiResponse({ + status: 200, + description: '작업물 삭제 성공', + schema: { + example: { + message: { + code: 200, + text: '작업물이 성공적으로 삭제되었습니다.', + }, + musicId: 1, + }, + }, + }), +}; + +export const UpdateGithubUsernameDocs = { + ApiOperation: ApiOperation({ + summary: 'GitHub 닉네임 업데이트', + description: '사용자의 GitHub 닉네임을 업데이트합니다.', + }), + ApiBody: ApiBody({ + description: '업데이트할 GitHub 닉네임', + schema: { + example: { + githubUsername: 'NewGithubUsername', + }, + }, + }), + ApiResponse: ApiResponse({ + status: 200, + description: 'GitHub 닉네임 업데이트 성공', + schema: { + example: { + message: { + code: 200, + text: '깃허브 유저네임 등록에 성공했습니다.', + }, + githubUsername: 'SSomae', + }, + }, + }), +}; + +export const GetUserSettingDocs = { + ApiOperation: ApiOperation({ + summary: '사용자 설정 정보 조회', + description: '사용자의 설정 정보를 조회합니다.', + }), + ApiResponse: ApiResponse({ + status: 200, + description: '유저 정보 세팅페이지 정보 조회에 성공했습니다', + schema: { + example: { + message: { + code: 200, + text: '유저 정보 세팅페이지 정보 조회에 성공했습니다.', + }, + nickname: 'UserNickname', + profileUrl: 'UserProfileURL', + introduce: 'User Introduce', + status: '구인 중', + links: [ + { linkId: 1, url: 'https://github.com/Ss0Mae' }, + { linkId: 2, url: 'https://www.google.com' }, + ], + skills: ['TypeScript', 'Nest.js'], + jobDetail: 'IT / 백엔드개발자', + notification: { + pushAlert: false, + followingAlert: false, + projectAlert: false, + }, + }, + }, + }), +}; + +export const PatchUserNicknameDocs = { + ApiOperation: ApiOperation({ + summary: '사용자 닉네임 업데이트', + description: '사용자의 닉네임을 업데이트합니다.', + }), + ApiBody: ApiBody({ + description: '업데이트할 닉네임', + schema: { + example: { + nickname: 'NewNickname', + }, + }, + }), + ApiResponse: ApiResponse({ + status: 200, + description: '닉네임 업데이트 성공', + schema: { + example: { + message: { + code: 200, + text: '닉네임이 성공적으로 업데이트되었습니다.', + }, + nickname: 'NewNickName', + }, + }, + }), +}; + +export const PatchUserIntroduceDocs = { + ApiOperation: ApiOperation({ + summary: '사용자 자기소개 업데이트', + description: '사용자의 자기소개 내용을 업데이트합니다.', + }), + ApiBody: ApiBody({ + description: '업데이트할 자기소개 내용', + schema: { + example: { + introduce: '안녕하세요. 저는 백엔드 개발자입니다.', + }, + }, + }), + ApiResponse: ApiResponse({ + status: 200, + description: '자기소개 업데이트 성공', + schema: { + example: { + message: { + code: 200, + text: '사용자의 소개가 성공적으로 업데이트되었습니다.', + }, + introduce: '안녕하세요. 저는 백엔드 개발자입니다.', + }, + }, + }), +}; + +export const PatchUserStatusDocs = { + ApiOperation: ApiOperation({ + summary: '사용자 상태 업데이트', + description: '사용자의 현재 상태를 업데이트합니다. (예: 활동 중, 휴식 중)', + }), + ApiBody: ApiBody({ + description: + '업데이트할 상태 ID 1 = 둘러보는중 2 = 외주 / 프로젝트 구하는 중 3 = 구인하는 중 4 = 작업 중', + schema: { + example: { + statusId: 1, // 예: 1=활동 중, 2=휴식 중 + }, + }, + }), + ApiResponse: ApiResponse({ + status: 200, + description: '상태 업데이트 성공', + schema: { + example: { + message: { + code: 200, + text: '사용자의 상태가 성공적으로 업데이트되었습니다.', + }, + status: '활동 중', + }, + }, + }), +}; + +export const UpdateUserJobDetailDocs = { + ApiOperation: ApiOperation({ + summary: '사용자 직업 세부정보 업데이트', + description: '사용자의 직업 카테고리 및 세부정보를 업데이트합니다.', + }), + ApiBody: ApiBody({ + description: '업데이트할 직업 카테고리 및 직무 상세', + schema: { + example: { + category: '개발자', + jobDetail: '백엔드 엔지니어', + }, + }, + }), + ApiResponse: ApiResponse({ + status: 200, + description: '직업 세부정보 업데이트 성공', + schema: { + example: { + message: { + code: 200, + text: '직무 정보가 성공적으로 업데이트되었습니다.', + }, + jobDetail: '개발자 / 백엔드 엔지니어', + }, + }, + }), +}; + +export const PatchUserSkillsDocs = { + ApiOperation: ApiOperation({ + summary: '사용자 기술 추가', + description: '사용자의 기술 스택에 새로운 기술을 추가합니다.', + }), + ApiBody: ApiBody({ + description: '추가할 기술 목록', + schema: { + example: { + skills: ['JavaScript', 'TypeScript', 'Node.js'], + }, + }, + }), + ApiResponse: ApiResponse({ + status: 200, + description: '기술 추가 성공', + schema: { + example: { + message: { + code: 200, + text: '기술 스택이 성공적으로 추가되었습니다.', + }, + skills: ['JavaScript', 'TypeScript', 'Node.js'], + }, + }, + }), +}; + +export const DeleteUserSkillsDocs = { + ApiOperation: ApiOperation({ + summary: '사용자 기술 삭제', + description: '사용자의 기술 스택에서 특정 기술들을 삭제합니다.', + }), + ApiBody: ApiBody({ + description: '삭제할 기술 목록', + schema: { + example: { + skills: ['JavaScript', 'TypeScript', 'Node.js'], + }, + }, + }), + ApiResponse: ApiResponse({ + status: 200, + description: '기술 삭제 성공', + schema: { + example: { + message: { + code: 200, + text: '기술 스택이 성공적으로 삭제되었습니다.', + }, + skills: ['JavaScript', 'TypeScript', 'Node.js'], + }, + }, + }), +}; + +export const PatchProfileImageDocs = { + ApiOperation: ApiOperation({ + summary: '프로필 이미지 업데이트', + description: '사용자의 프로필 이미지를 업데이트합니다.', + }), + ApiConsumes: ApiConsumes('multipart/form-data'), + ApiBody: ApiBody({ + description: '업데이트할 이미지 파일', + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + }, + }, + }, + }), + ApiResponse: ApiResponse({ + status: 200, + description: '프로필 이미지 업데이트 성공', + schema: { + example: { + message: { + code: 200, + text: '프로필 이미지가 성공적으로 업데이트되었습니다.', + }, + user: { + userId: 1, + nickname: 'ssomae', + profileUrl: 'https://example.com/profiles/ssomae.jpg', + }, + }, + }, + }), +}; + +export const PatchUserNotificationDocs = { + ApiOperation: ApiOperation({ + summary: '사용자 알림 설정 업데이트', + description: + '사용자의 알림 설정(push, following, project)을 업데이트합니다.', + }), + ApiBody: ApiBody({ + description: '업데이트할 알림 설정', + schema: { + example: { + notification: { + pushAlert: true, + followingAlert: false, + projectAlert: true, + }, + }, + }, + }), + ApiResponse: ApiResponse({ + status: 200, + description: '알림 설정 업데이트 성공', + schema: { + example: { + message: { + code: 200, + text: '알림 설정이 성공적으로 업데이트되었습니다.', + }, + notifications: { + pushAlert: true, + followingAlert: false, + projectAlert: true, + }, + }, + }, + }), +}; + +export const AddUserLinksDocs = { + ApiOperation: ApiOperation({ + summary: '사용자 링크 추가', + description: '사용자의 프로필에 새로운 링크를 추가합니다.', + }), + ApiBody: ApiBody({ + description: '추가할 링크 목록', + schema: { + example: { + url: 'https://github.com/user', + }, + }, + }), + ApiResponse: ApiResponse({ + status: 201, + description: '링크 추가 성공', + schema: { + example: { + message: { + code: 201, + text: '링크가 성공적으로 추가되었습니다.', + }, + links: [ + { linkId: 1, url: 'https://github.com/user' }, + { linkId: 2, url: 'https://linkedin.com/in/user' }, + ], + }, + }, + }), +}; + +export const DeleteUserLinksDocs = { + ApiOperation: ApiOperation({ + summary: '사용자 링크 삭제', + description: '사용자의 프로필에서 특정 링크를 삭제합니다.', + }), + ApiBody: ApiBody({ + description: '삭제할 링크 ID 목록', + schema: { + example: { + linkId: 1, + }, + }, + }), + ApiResponse: ApiResponse({ + status: 200, + description: '링크 삭제 성공', + schema: { + example: { + message: { + code: 200, + text: '링크가 성공적으로 삭제되었습니다.', + }, + links: [ + { linkId: 3, url: 'https://twitter.com/user' }, + { linkId: 4, url: 'https://facebook.com/user' }, + ], + }, + }, + }), +}; + +export const UpdateUserLinksDocs = { + ApiOperation: ApiOperation({ + summary: '링크 수정', + description: '사용자의 특정 링크를 수정합니다.', + }), + ApiBody: ApiBody({ + description: '수정할 링크의 ID와 새로운 URL 정보', + schema: { + type: 'object', + properties: { + linkId: { + type: 'number', + description: '수정할 링크의 ID', + example: 1, + }, + url: { + type: 'string', + description: '새로운 링크 URL', + example: 'https://example.com', + }, + }, + required: ['linkId', 'url'], + }, + }), + ApiResponse: ApiResponse({ + status: 200, + description: '링크가 성공적으로 수정되었습니다.', + schema: { + type: 'object', + properties: { + message: { + type: 'object', + properties: { + code: { type: 'number', example: 200 }, + text: { + type: 'string', + example: '링크가 성공적으로 수정되었습니다.', + }, + }, + }, + links: { + type: 'array', + items: { + type: 'object', + properties: { + linkId: { type: 'number', example: 1 }, + url: { type: 'string', example: 'https://example.com' }, + }, + }, + }, + }, + }, + }), +}; + +export const GetUserResumeDocs = { + ApiOperation: ApiOperation({ + summary: '사용자 이력서 조회', + description: '특정 사용자의 이력서를 조회합니다.', + }), + ApiParam: ApiParam({ + name: 'userId', + required: true, + description: '조회할 사용자의 ID', + type: 'string', + }), + ApiResponse: ApiResponse({ + status: 200, + description: '이력서 조회 성공', + schema: { + example: { + message: { + code: 200, + text: '사용자 이력서 조회에 성공했습니다.', + }, + userId: 1, + resumeId: 2, + title: 'Backend Developer', + jobDetail: '개발자 / 백엔드 엔지니어', + skills: ['Node.js', 'TypeScript', 'GraphQL'], + portfolioUrl: 'https://portfolio.com/user', + detail: '경력 및 프로젝트 설명', + isOwnProfile: true, + }, + }, + }), +}; + +export const CreateUserResumeDocs = { + ApiOperation: ApiOperation({ + summary: '사용자 이력서 생성', + description: '새로운 이력서를 생성합니다.', + }), + ApiBody: ApiBody({ + description: '생성할 이력서 정보', + schema: { + example: { + title: 'Frontend Developer', + portfolioUrl: 'https://github.com/user', + detail: '5년간 프론트엔드 개발 경력.', + }, + }, + }), + ApiResponse: ApiResponse({ + status: 201, + description: '이력서 생성 성공', + schema: { + example: { + message: { + code: 201, + text: '사용자 이력서 생성에 성공했습니다.', + }, + resume: { + userId: 1, + resumeId: 1, + title: 'Resume Title', + portfolioUrl: 'portfolioURL', + detail: 'Resume Detail', + }, + }, + }, + }), +}; + +export const UpdateUserResumeDocs = { + ApiOperation: ApiOperation({ + summary: '사용자 이력서 수정', + description: '기존 이력서를 수정합니다.', + }), + ApiParam: ApiParam({ + name: 'resumeId', + required: true, + description: '수정할 이력서의 ID', + type: 'string', + }), + ApiBody: ApiBody({ + description: '수정할 이력서 정보', + schema: { + example: { + title: 'Updated Developer Title', + portfolioUrl: 'https://updated.github.com/user', + detail: 'Updated details about the resume.', + }, + }, + }), + ApiResponse: ApiResponse({ + status: 200, + description: '이력서 수정 성공', + schema: { + example: { + message: { + code: 200, + text: '사용자 이력서 수정에 성공했습니다.', + }, + updatedResume: { + userId: 1, + resumeId: 1, + title: 'Resume Title', + portfolioUrl: 'portfolioURL', + detail: 'Resume Detail', + }, + }, + }, + }), +}; + +export const DeleteUserResumeDocs = { + ApiOperation: ApiOperation({ + summary: '사용자 이력서 삭제', + description: '기존 이력서를 삭제합니다.', + }), + ApiParam: ApiParam({ + name: 'resumeId', + required: true, + description: '삭제할 이력서의 ID', + type: 'string', + }), + ApiResponse: ApiResponse({ + status: 200, + description: '이력서 삭제 성공', + schema: { + example: { + message: { + code: 200, + text: '사용자 이력서 삭제에 성공했습니다.', + }, + resumeId: 1, + }, + }, + }), +}; + +export const GetUserFeedPostsDocs = { + ApiOperation: ApiOperation({ + summary: '사용자 피드 조회', + description: '특정 사용자가 작성한 피드 목록을 조회합니다.', + }), + ApiParam: ApiParam({ + name: 'userId', + required: true, + description: '피드를 조회할 사용자의 ID', + type: 'string', + }), + ApiQuery: [ + ApiQuery({ + name: 'page', + required: false, + description: '페이지 번호 (기본값: 1)', + type: 'number', + }), + ApiQuery({ + name: 'limit', + required: false, + description: '페이지 당 항목 수 (기본값: 10)', + type: 'number', + }), + ], + ApiResponse: ApiResponse({ + status: 200, + description: '사용자 피드 조회 성공', + schema: { + example: { + message: { + code: 200, + text: '사용자 피드 조회에 성공했습니다.', + }, + feeds: [ + { + id: 1, + title: 'My First Feed', + content: 'This is the content of my feed.', + thumbnailUrl: 'https://example.com/thumbnail.jpg', + createdAt: '2023-01-01T00:00:00.000Z', + view: 100, + likeCount: 10, + commentCount: 5, + user: { + id: 1, + nickname: 'JohnDoe', + profileUrl: 'https://example.com/profile.jpg', + }, + tags: ['Tag1', 'Tag2'], + }, + ], + pagination: { lastCursor: 10 }, + }, + }, + }), +}; + +export const GetUserConnectionHubProjectsDocs = { + ApiOperation: ApiOperation({ + summary: '사용자 커넥션 허브 조회', + description: '특정 사용자가 생성하거나 지원한 프로젝트 목록을 조회합니다.', + }), + ApiParam: ApiParam({ + name: 'userId', + required: true, + description: '프로젝트를 조회할 사용자의 ID', + type: 'string', + }), + ApiQuery: [ + ApiQuery({ + name: 'type', + required: true, + description: "프로젝트 유형 ('applied' 또는 'created')", + enum: ['applied', 'created'], + }), + ApiQuery({ + name: 'page', + required: false, + description: '페이지 번호 (기본값: 1)', + type: 'number', + }), + ApiQuery({ + name: 'limit', + required: false, + description: '페이지 당 항목 수 (기본값: 10)', + type: 'number', + }), + ], + ApiResponse: ApiResponse({ + status: 200, + description: '커넥션 허브 프로젝트 조회 성공', + schema: { + example: { + message: { code: 200, text: '프로젝트 조회 성공' }, + projects: [ + { + projectPostId: 1, + title: '프로젝트 제목', + content: '프로젝트 설명', + thumbnailUrl: 'https://example.com/thumbnail.jpg', + role: 'Programmer', + skills: ['React', 'TypeScript'], + detailRoles: ['프론트엔드 개발자'], + hubType: 'Project', + startDate: '2024-01-01', + duration: 6, + workType: 'OFFLINE', + applyCount: 10, + bookMarkCount: 5, + viewCount: 100, + status: 'OPEN', + }, + ], + pagination: { lastCursor: 10 }, + }, + }, + }), +}; diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts index 4bbfcb3..332b209 100644 --- a/src/modules/user/user.controller.ts +++ b/src/modules/user/user.controller.ts @@ -1,7 +1,475 @@ -import { Controller } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Param, + Patch, + Post, + Req, + UseGuards, + UseInterceptors, + UploadedFile, + BadRequestException, + Delete, + Put, + HttpException, + HttpStatus, + Query, +} from '@nestjs/common'; import { UserService } from './user.service'; +import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { + GetUserFollowersDocs, + GetUserFollowingsDocs, + GetUserProfileDocs, + GetUserProfileHeaderDocs, + AddProjectDocs, + UpdateProjectDocs, + DeleteProjectDocs, + AddWorkDocs, + UpdateWorkDocs, + DeleteWorkDocs, + PatchUserNicknameDocs, + GetUserSettingDocs, + UpdateGithubUsernameDocs, + UpdateUserJobDetailDocs, + PatchUserStatusDocs, + PatchUserIntroduceDocs, + PatchUserSkillsDocs, + DeleteUserSkillsDocs, + PatchProfileImageDocs, + PatchUserNotificationDocs, + AddUserLinksDocs, + DeleteUserLinksDocs, + GetUserResumeDocs, + CreateUserResumeDocs, + UpdateUserResumeDocs, + DeleteUserResumeDocs, + GetUserFeedPostsDocs, + GetUserConnectionHubProjectsDocs, + UpdateUserLinksDocs, +} from './docs/user.docs'; +import { ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { S3Service } from '@src/s3/s3.service'; +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) @Controller('users') export class UserController { - constructor(private readonly userService: UserService) {} + constructor( + private readonly userService: UserService, + private readonly s3Service: S3Service + ) {} + + @Get(':userId') + @GetUserProfileDocs.ApiOperation + @GetUserProfileDocs.ApiParam + @GetUserProfileDocs.ApiResponse + async getUserProfile(@Param('userId') userId: string, @Req() req) { + const loggedInUserId = req.user?.user_id; + const numUserId = parseInt(userId); // 인증된 사용자 ID + return this.userService.getUserProfile(loggedInUserId, numUserId); + } + + @Get(':nickname/headers') + @GetUserProfileHeaderDocs.ApiOperation + @GetUserProfileHeaderDocs.ApiParam + @GetUserProfileHeaderDocs.ApiResponse + async getUserProfileHeader(@Param('nickname') nickname: string, @Req() req) { + const loggedInUserId = req.user?.user_id; + return this.userService.getUserProfileHeaderByNickname( + loggedInUserId, + nickname + ); + } + + @Get(':userId/followers') + @GetUserFollowersDocs.ApiOperation + @GetUserFollowersDocs.ApiResponse + async getUserFollowers(@Param('userId') userId: string) { + const numUserId = parseInt(userId); // 인증된 사용자 ID + return this.userService.getUserFollowers(numUserId); + } + + @Get(':userId/following') + @GetUserFollowingsDocs.ApiOperation + @GetUserFollowingsDocs.ApiResponse + async getUserFollowings(@Param('userId') userId: string) { + const numUserId = parseInt(userId); // 인증된 사용자 ID + return this.userService.getUserFollowings(numUserId); + } + + @Post('projects') + @UseInterceptors(FileInterceptor('file')) + @AddProjectDocs.ApiOperation + @AddProjectDocs.ApiBody + @AddProjectDocs.ApiResponse + async addProject( + @Req() req, + @UploadedFile() file: Express.Multer.File, + @Body() body: any + ) { + const userId = req.user?.user_id; + let imageUrl = null; + if (file) { + imageUrl = await this.s3Service.uploadImage( + userId, + file.buffer, + file.mimetype.split('/')[1], // 파일 확장자 추출 + 'pad_projects/images' // S3 저장 경로 설정 + ); + } + const projectData = { + ...body, + links: JSON.parse(body.links), // 문자열을 객체로 변환 + }; + return this.userService.addProject(userId, projectData, imageUrl); + } + + @Put('projects/:projectId') + @UseInterceptors(FileInterceptor('file')) // 파일 처리 인터셉터 추가 + @UpdateProjectDocs.ApiOperation + @UpdateProjectDocs.ApiParam + @UpdateProjectDocs.ApiBody + @UpdateProjectDocs.ApiResponse + async updateProject( + @Req() req, + @Param('projectId') projectId: string, + @UploadedFile() file: Express.Multer.File, // 업로드된 파일 처리 + @Body() body: any + ) { + const userId = req.user?.user_id; + const numProjectId = parseInt(projectId, 10); + + // 이미지 업로드 처리 + let imageUrl = null; + if (file) { + imageUrl = await this.s3Service.uploadImage( + userId, + file.buffer, + file.mimetype.split('/')[1], // 파일 확장자 추출 + 'pad_projects/images' // S3 저장 경로 설정 + ); + } + // body의 links 필드 처리 (JSON 문자열을 객체로 변환) + const projectData = { + ...body, + links: body.links ? JSON.parse(body.links) : [], // links가 있으면 파싱, 없으면 빈 배열 + }; + + return this.userService.updateProject( + userId, + numProjectId, + projectData, + imageUrl + ); + } + + @Delete('projects/:projectId') + @DeleteProjectDocs.ApiOperation + @DeleteProjectDocs.ApiParam + @DeleteProjectDocs.ApiResponse + async deleteProject(@Req() req, @Param('projectId') projectId: string) { + const userId = req.user?.user_id; + const numProjectId = parseInt(projectId, 10); + return this.userService.deleteProject(userId, numProjectId); + } + + @Post('artist/works') + @AddWorkDocs.ApiOperation + @AddWorkDocs.ApiBody + @AddWorkDocs.ApiResponse + async addWork(@Req() req, @Body('musicUrl') musicUrl: string) { + const userId = req.user?.user_id; + return this.userService.addArtistWork(userId, musicUrl); + } + + @Put('artist/works/:workId') + @UpdateWorkDocs.ApiOperation + @UpdateWorkDocs.ApiParam + @UpdateWorkDocs.ApiBody + @UpdateWorkDocs.ApiResponse + async updateWork( + @Req() req, + @Param('workId') workId: string, + @Body() musicUrl: string + ) { + const userId = req.user?.user_id; + const numWorkId = parseInt(workId, 10); + return this.userService.updateArtistWork(userId, numWorkId, musicUrl); + } + + @Delete('artist/works/:workId') + @DeleteWorkDocs.ApiOperation + @DeleteWorkDocs.ApiParam + @DeleteWorkDocs.ApiResponse + async deleteWork(@Req() req, @Param('workId') workId: string) { + const userId = req.user?.user_id; + const numWorkId = parseInt(workId, 10); + return this.userService.deleteArtistWork(userId, numWorkId); + } + + @Patch('githubNickname') + @UpdateGithubUsernameDocs.ApiOperation + @UpdateGithubUsernameDocs.ApiBody + @UpdateGithubUsernameDocs.ApiResponse + async updateGithubUsername( + @Req() req, + @Body('githubUsername') githubUsername: string + ) { + const userId = req.user?.user_id; + return this.userService.updateGithubUsername(userId, githubUsername); + } + + @Get('profile/settings') + @GetUserSettingDocs.ApiOperation + @GetUserSettingDocs.ApiResponse + async getUserSetting(@Req() req) { + const userId = req.user?.user_id; + return this.userService.getUserSetting(userId); + } + + @Patch('profile/nickname') + @PatchUserNicknameDocs.ApiOperation + @PatchUserNicknameDocs.ApiBody + @PatchUserNicknameDocs.ApiResponse + async patchUserNickname(@Req() req, @Body('nickname') nickname: string) { + const userId = req.user?.user_id; + return this.userService.patchUserNickname(userId, nickname); + } + + @Patch('profile/introduce') + @PatchUserIntroduceDocs.ApiOperation + @PatchUserIntroduceDocs.ApiBody + @PatchUserIntroduceDocs.ApiResponse + async patchUserIntroduce(@Req() req, @Body('introduce') introduce: string) { + const userId = req.user?.user_id; + return this.userService.patchUserIntroduce(userId, introduce); + } + + @Patch('profile/status') + @PatchUserStatusDocs.ApiOperation + @PatchUserStatusDocs.ApiBody + @PatchUserStatusDocs.ApiResponse + async patchUserStatus(@Req() req, @Body('statusId') statusId: number) { + const userId = req.user?.user_id; + return this.userService.patchUserStatus(userId, statusId); + } + + @Patch('profile/job') + @UpdateUserJobDetailDocs.ApiOperation + @UpdateUserJobDetailDocs.ApiBody + @UpdateUserJobDetailDocs.ApiResponse + async updateUserJobDetail( + @Req() req, + @Body('category') category: string, + @Body('jobDetail') jobDetail: string + ) { + const userId = req.user?.user_id; + return this.userService.updateUserJobDetail(userId, category, jobDetail); + } + + @Post('profile/skills') + @PatchUserSkillsDocs.ApiOperation + @PatchUserSkillsDocs.ApiBody + @PatchUserSkillsDocs.ApiResponse + async patchUserSkills(@Req() req, @Body('skills') skills: string[]) { + const userId = req.user?.user_id; + return this.userService.addUserSkills(userId, skills); + } + + @Delete('profile/skills') + @DeleteUserSkillsDocs.ApiOperation + @DeleteUserSkillsDocs.ApiBody + @DeleteUserSkillsDocs.ApiResponse + async deleteUserSkills(@Req() req, @Body('skills') skills: string[]) { + const userId = req.user?.user_id; + return this.userService.deleteUserSkills(userId, skills); + } + + @Patch('profile/image') + @PatchProfileImageDocs.ApiOperation + @PatchProfileImageDocs.ApiConsumes + @PatchProfileImageDocs.ApiBody + @PatchProfileImageDocs.ApiResponse + @UseInterceptors(FileInterceptor('file')) + async patchProfileImage( + @Req() req, + @UploadedFile() file: Express.Multer.File + ) { + const userId = req.user?.user_id; + if (!file) { + throw new BadRequestException('파일이 업로드되지 않았습니다'); + } + const fileType = file.mimetype.split('/')[1]; + return this.userService.patchProfileImage(userId, file.buffer, fileType); + } + + @Patch('profile/notification') + @PatchUserNotificationDocs.ApiOperation + @PatchUserNotificationDocs.ApiBody + @PatchUserNotificationDocs.ApiResponse + async patchUserNotification( + @Req() req, + @Body('notification') + notifications: { + pushAlert?: boolean; + followingAlert?: boolean; + projectAlert?: boolean; + } + ) { + const userId = req.user?.user_id; + return this.userService.patchUserNotification(userId, notifications); + } + + @Post('profile/links') + @AddUserLinksDocs.ApiOperation + @AddUserLinksDocs.ApiBody + @AddUserLinksDocs.ApiResponse + async addUserLinks(@Req() req, @Body('url') url: string) { + const userId = req.user?.user_id; + return this.userService.addUserLink(userId, url); + } + + @Delete('profile/links') + @DeleteUserLinksDocs.ApiOperation + @DeleteUserLinksDocs.ApiBody + @DeleteUserLinksDocs.ApiResponse + async deleteUserLinks(@Req() req, @Body('linkId') linkId: number) { + const userId = req.user?.user_id; + return this.userService.deleteUserLink(userId, linkId); + } + + @Patch('profile/links') + @UpdateUserLinksDocs.ApiOperation + @UpdateUserLinksDocs.ApiBody + @UpdateUserLinksDocs.ApiResponse + async updateUserLink( + @Req() req, + @Body() updateData: { linkId: number; url: string } + ) { + const userId = req.user?.user_id; + return this.userService.updateUserLink( + userId, + updateData.linkId, + updateData.url + ); + } + + @Delete('account') + async deleteAccount(@Req() req) { + const userId = req.user?.user_id; // 인증된 사용자 ID 가져오기 + if (!userId) { + throw new HttpException( + '유효하지 않은 사용자입니다.', + HttpStatus.FORBIDDEN + ); + } + + await this.userService.deleteAccount(userId); + + return { + message: '계정이 성공적으로 삭제되었습니다.', + }; + } + + @Get('profile/resume/:userId') + @GetUserResumeDocs.ApiOperation + @GetUserResumeDocs.ApiParam + @GetUserResumeDocs.ApiResponse + async getUserResume(@Req() req, @Param('userId') targetUserId: string) { + const loggedInUserId = req.user?.user_id; + const numUserId = parseInt(targetUserId, 10); + return this.userService.getUserResume(loggedInUserId, numUserId); + } + + @Post('profile/resume') + @CreateUserResumeDocs.ApiOperation + @CreateUserResumeDocs.ApiBody + @CreateUserResumeDocs.ApiResponse + async createUserResume( + @Req() req, + @Body() body: { title: string; portfolioUrl?: string; detail: string } + ) { + const userId = req.user?.user_id; + return this.userService.createUserResume(userId, body); + } + + // 지원서 수정 + @Patch('profile/resume/:resumeId') + @UpdateUserResumeDocs.ApiOperation + @UpdateUserResumeDocs.ApiParam + @UpdateUserResumeDocs.ApiBody + @UpdateUserResumeDocs.ApiResponse + async updateUserResume( + @Req() req, + @Param('resumeId') resumeId: string, + @Body() body: { title?: string; portfolioUrl?: string; detail?: string } + ) { + const userId = req.user?.user_id; + const numResumeId = parseInt(resumeId, 10); + return this.userService.updateUserResume(userId, numResumeId, body); + } + + @Delete('profile/resume/:resumeId') + @DeleteUserResumeDocs.ApiOperation + @DeleteUserResumeDocs.ApiParam + @DeleteUserResumeDocs.ApiResponse + async deleteUserResume(@Req() req, @Param('resumeId') resumeId: string) { + const userId = req.user?.user_id; + const numResumeId = parseInt(resumeId, 10); + return this.userService.deleteUserResume(userId, numResumeId); + } + + @Get(':userId/feeds') + @GetUserFeedPostsDocs.ApiOperation + @GetUserFeedPostsDocs.ApiParam + @ApiQuery({ + name: 'cursor', + required: false, + type: Number, + description: '마지막으로 조회된 피드 ID (무한스크롤 구현을 위한 커서)', + }) + @GetUserFeedPostsDocs.ApiResponse + async getUserFeedPosts( + @Param('userId') userId: string, + @Query('cursor') cursor?: number + ) { + const numUserId = parseInt(userId, 10); + const limit = 10; + return this.userService.getFeeds(numUserId, cursor, limit); + } + + @Get(':userId/connection-hub') + @GetUserConnectionHubProjectsDocs.ApiOperation + @GetUserConnectionHubProjectsDocs.ApiParam + @ApiQuery({ + name: 'cursor', + required: false, + type: Number, + description: '마지막으로 조회된 프로젝트 ID (무한스크롤 구현을 위한 커서)', + }) + @ApiQuery({ + name: 'type', + required: false, + enum: ['applied', 'created'], + description: + '프로젝트 유형 (`created`: 생성한 프로젝트, `applied`: 지원한 프로젝트)', + }) + @GetUserConnectionHubProjectsDocs.ApiResponse + async getUserConnectionHubProjects( + @Param('userId') userId: string, + @Query('cursor') cursor?: number, + @Query('type') type: 'applied' | 'created' = 'created' // type 기본값 설정 + ) { + const numUserId = parseInt(userId, 10); + const limit = 10; + return this.userService.getConnectionHubProjects( + numUserId, + type, + cursor, + limit + ); + } } diff --git a/src/modules/user/user.module.ts b/src/modules/user/user.module.ts index aedd372..cd02609 100644 --- a/src/modules/user/user.module.ts +++ b/src/modules/user/user.module.ts @@ -3,9 +3,10 @@ import { AuthModule } from '@modules/auth/auth.module'; import { UserService } from './user.service'; import { UserController } from './user.controller'; import { PrismaService } from '@prisma/prisma.service'; +import { S3Module } from '@src/s3/s3.module'; @Module({ - imports: [AuthModule], // AuthModule을 가져옴 + imports: [AuthModule, S3Module], // AuthModule을 가져옴 controllers: [UserController], providers: [UserService, PrismaService], exports: [UserService], diff --git a/src/modules/user/user.service.ts b/src/modules/user/user.service.ts index 757992d..3a2b1be 100644 --- a/src/modules/user/user.service.ts +++ b/src/modules/user/user.service.ts @@ -1,8 +1,1244 @@ -import { Injectable } from '@nestjs/common'; - +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { PrismaService } from '@prisma/prisma.service'; +import { S3Service } from '@src/s3/s3.service'; @Injectable() export class UserService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly s3Service: S3Service + ) {} + + async getUserProfile(loggedInUserId: number, targetUserId: number) { + // 조회 대상 사용자의 프로필 정보 가져오기 + const user = await this.prisma.user.findUnique({ + where: { id: targetUserId }, + include: { + role: true, // 역할 정보 + status: true, // 상태 정보 + ArtistData: true, // 아티스트 데이터 + ProgrammerData: true, // 프로그래머 데이터 + UserLinks: true, // 연결된 링크 + MyPageProject: { + include: { + ProjectLinks: { + include: { + type: true, // LinkType 포함 + }, + }, + }, + }, + }, + }); + + if (!user) { + throw new NotFoundException('사용자를 찾을 수 없습니다.'); + } + + // 팔로워 수와 팔로잉 수 계산 + const followerCount = await this.prisma.follows.count({ + where: { followed_user_id: targetUserId }, + }); + + const followingCount = await this.prisma.follows.count({ + where: { following_user_id: targetUserId }, + }); + + // 유저가 작성한 피드 개수 + const feedCount = await this.prisma.feedPost.count({ + where: { user_id: targetUserId }, + }); + + // 유저가 지원한 프로젝트의 개수 + const applyCount = await this.prisma.userApplyProject.count({ + where: { user_id: targetUserId }, + }); + // 반환 데이터 구성 + const response = { + message: { + code: 200, + text: '유저 프로필 조회에 성공했습니다', + }, + status: user.status.name, + followerCount, // 팔로워 수 + followingCount, // 팔로잉 수 + feedCount, // 유저가 작성한 피드 개수 + applyCount, // 유저가 지원한 프로젝트 개수 + isOwnProfile: loggedInUserId === targetUserId, // 자신의 프로필인지 확인 + }; + + // 사용자 직업군에 따라 데이터를 동적으로 추가 + if (user.role.name === 'Artist') { + Object.assign(response, { + works: user.ArtistData.map(work => ({ + musicUrl: work.music_url, + musicId: work.id, + })), // 단순 URL 배열로 변환 + }); + } else if ( + user.role.name === 'Programmer' || + user.role.name === 'Designer' + ) { + Object.assign(response, { + githubUsername: user.ProgrammerData?.github_username || null, + works: user.MyPageProject + ? user.MyPageProject.map(project => ({ + title: project.title, + description: project.description, + myPageProjectId: project.id, + projectProfileUrl: project.projectProfileUrl, + links: project.ProjectLinks.map(link => ({ + type: link.type.name, + url: link.url, + })), + })) + : [], + }); + } + + return response; + } + + async getUserProfileHeaderByNickname( + loggedInUserId: number, + nickname: string + ) { + // 닉네임으로 사용자 조회 + const user = await this.prisma.user.findUnique({ + where: { nickname }, // 닉네임을 기준으로 조회 + include: { + role: true, // 역할 정보 + UserLinks: true, // 연결된 링크 + }, + }); + + if (!user) { + throw new NotFoundException('사용자를 찾을 수 없습니다.'); + } + + // 로그인한 사용자가 해당 유저를 팔로우하고 있는지 확인 + const isFollowing = await this.prisma.follows.findFirst({ + where: { + following_user_id: loggedInUserId, + followed_user_id: user.id, + }, + }); + + // 반환 데이터 구성 + return { + message: { + code: 200, + text: '유저 프로필(헤더 부분) 조회에 성공했습니다', + }, + userId: user.id, + nickname: user.nickname, + profileUrl: user.profile_url, + role: user.role.name, + introduce: user.introduce, + userLinks: user.UserLinks.map(link => link.link), // 단순 URL 배열로 변경 + isOwnProfile: loggedInUserId === user.id, // 자신의 프로필인지 확인 + isFollowing: !!isFollowing, // 팔로우 여부 확인 + }; + } + + async getUserFollowers(targetUserId: number) { + // 특정 사용자를 팔로우하는 사용자 목록 조회 + const followers = await this.prisma.follows.findMany({ + where: { + followed_user_id: targetUserId, // 타겟 유저를 팔로우하는 사용자 + }, + include: { + following_user: { + select: { + id: true, + nickname: true, + profile_url: true, // 프로필 이미지 + }, + }, + }, + }); + + // 반환 데이터 생성 + return { + message: { + code: 200, + text: '팔로잉 목록 조회에 성공했습니다', + }, + followerUsers: followers.map(follower => ({ + userId: follower.following_user.id, + nickname: follower.following_user.nickname, + profileUrl: follower.following_user.profile_url, + })), + }; + } + + async getUserFollowings(targetUserId: number) { + const followings = await this.prisma.follows.findMany({ + where: { + following_user_id: targetUserId, // 타겟 유저가 팔로우한 사용자 + }, + include: { + followed_user: { + select: { + id: true, + nickname: true, + profile_url: true, // 프로필 이미지 + }, + }, + }, + }); + + // 반환 데이터 생성 + return { + message: { + code: 200, + text: '팔로잉 목록 조회에 성공했습니다', + }, + followingUsers: followings.map(following => ({ + userId: following.followed_user.id, + nickname: following.followed_user.nickname, + profileUrl: following.followed_user.profile_url, + })), + }; + } + + async addProject(userId: number, projectData: any, imageUrl: string | null) { + const { title, description, links } = projectData; + + // 작업물 추가 + const newProject = await this.prisma.myPageProject.create({ + data: { + user_id: userId, + title, + description, + projectProfileUrl: imageUrl, + ProjectLinks: { + create: links.map(link => ({ + url: link.url, + type_id: link.typeId, // LinkType의 ID를 사용 + })), + }, + }, + include: { + ProjectLinks: { + include: { type: true }, + }, + }, + }); + + return { + message: { + code: 201, + text: '마이페이지에 프로젝트 추가에 성공했습니다', + }, + myPageProjectId: newProject.id, + title: newProject.title, + description: newProject.description, + projectProfileUrl: newProject.projectProfileUrl, + links: newProject.ProjectLinks.map(link => ({ + url: link.url, + type: link.type.name, + })), + }; + } + + async updateProject( + userId: number, + projectId: number, + projectData: any, + imageUrl: string | null + ) { + const { title, description, links } = projectData; + + // 기존 프로젝트 확인 + const existingProject = await this.prisma.myPageProject.findFirst({ + where: { + id: projectId, + user_id: userId, + }, + include: { + ProjectLinks: true, // 기존 링크를 가져옴 + }, + }); + + if (!existingProject) { + throw new NotFoundException('작업물을 찾을 수 없습니다.'); + } + + // 파일이 없는 경우 기존의 projectProfileUrl 유지 + const finalImageUrl = imageUrl || existingProject.projectProfileUrl; + + // 링크 업데이트 및 추가 + if (links) { + for (const link of links) { + const existingLink = await this.prisma.myPageProjectLink.findFirst({ + where: { + project_id: projectId, + type_id: link.typeId, + }, + }); + + if (existingLink) { + // 기존 링크가 있으면 업데이트 + await this.prisma.myPageProjectLink.update({ + where: { id: existingLink.id }, + data: { + url: link.url, + }, + }); + } else { + // 기존 링크가 없으면 새로 생성 + await this.prisma.myPageProjectLink.create({ + data: { + project_id: projectId, + url: link.url, + type_id: link.typeId, + }, + }); + } + } + } + + // 프로젝트 자체 업데이트 + const updatedProject = await this.prisma.myPageProject.update({ + where: { id: projectId }, + data: { + title: title || existingProject.title, // title이 없으면 기존 값 유지 + description: description || existingProject.description, // description이 없으면 기존 값 유지 + projectProfileUrl: finalImageUrl, // 이미지 URL 업데이트 + }, + include: { + ProjectLinks: { + include: { type: true }, + }, + }, + }); + + // 최종적으로 모든 링크를 가져옴 + const allLinks = await this.prisma.myPageProjectLink.findMany({ + where: { project_id: projectId }, + include: { type: true }, // type 필드 포함 + }); + + return { + message: { + code: 200, + text: '마이페이지에 프로젝트 수정에 성공했습니다', + }, + myPageProjectId: updatedProject.id, + title: updatedProject.title, + description: updatedProject.description, + projectProfileUrl: updatedProject.projectProfileUrl, + links: allLinks.map(link => ({ + url: link.url, + type: link.type.name, + })), + }; + } + + async deleteProject(userId: number, projectId: number) { + // 1. 프로젝트 존재 여부 확인 + const existingProject = await this.prisma.myPageProject.findFirst({ + where: { + id: projectId, + user_id: userId, + }, + }); + + if (!existingProject) { + throw new NotFoundException('작업물을 찾을 수 없습니다.'); + } + + // 2. 트랜잭션을 사용하여 ProjectLinks와 myPageProject 삭제 + await this.prisma.$transaction([ + this.prisma.myPageProjectLink.deleteMany({ + where: { project_id: projectId }, + }), + this.prisma.myPageProject.delete({ + where: { id: projectId }, + }), + ]); + + // 3. 반환 데이터 구성 + return { + message: { + code: 200, + text: '마이페이지에 프로젝트 삭제에 성공했습니다', + }, + projectId, + }; + } + + async getUserSetting(userId: number) { + // 사용자 정보를 가져옵니다. + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + include: { + UserLinks: true, // 링크 정보만 포함 + UserSkills: { + include: { + skill: true, // Skill 정보를 포함 + }, + }, + status: true, // 상태 정보 포함 + }, + }); + + if (!user) { + throw new NotFoundException('사용자를 찾을 수 없습니다.'); + } + + // 데이터 반환 + return { + message: { + code: 200, + text: '유저 정보 세팅페이지 정보 조회에 성공했습니다', + }, + nickname: user.nickname, + profileUrl: user.profile_url, + introduce: user.introduce, + status: user.status?.name, + links: user.UserLinks.map(link => ({ + linkId: link.id, + url: link.link, // 링크 정보만 반환 + })), + skills: user.UserSkills.map(skill => skill.skill.name), // 기술 스택 + jobDetail: user.job_detail, + notifications: { + pushAlert: user.push_alert, + followingAlert: user.following_alert, + projectAlert: user.project_alert, + }, + }; + } + + async patchUserNickname(userId: number, nickname: string) { + if (!nickname || nickname.trim().length === 0) { + throw new BadRequestException('닉네임은 비어 있을 수 없습니다.'); + } + + // 닉네임 업데이트 + const updatedUser = await this.prisma.user.update({ + where: { id: userId }, + data: { + nickname, + }, + }); + + return { + message: { + code: 200, + text: '유저 닉네임이 성공적으로 변경되었습니다', + }, + nickname: updatedUser.nickname, + }; + } + + async updateUserJobDetail( + userId: number, + category: string, + jobDetail: string + ) { + const jobDetailString = `${category} / ${jobDetail}`; + + const user = await this.prisma.user.update({ + where: { id: userId }, + data: { job_detail: jobDetailString }, + }); + + return { + message: { + code: 200, + text: '직무 정보가 성공적으로 업데이트되었습니다.', + }, + jobDetail: user.job_detail, + }; + } + + async patchUserIntroduce(userId: number, introduce: string) { + // 사용자의 한 줄 소개 업데이트 + const updatedUser = await this.prisma.user.update({ + where: { id: userId }, + data: { introduce }, + }); + + return { + message: { + code: 200, + text: '사용자의 소개가 성공적으로 업데이트되었습니다.', + }, + introduce: updatedUser.introduce, + }; + } + + async patchUserStatus(userId: number, statusId: number) { + // Status ID가 유효한지 확인 + const status = await this.prisma.status.findUnique({ + where: { id: statusId }, + }); + + if (!status) { + throw new NotFoundException('유효하지 않은 상태 ID입니다.'); + } + + // 사용자의 상태 업데이트 + const updatedUser = await this.prisma.user.update({ + where: { id: userId }, + data: { status_id: statusId }, + }); + + return { + message: { + code: 200, + text: '사용자의 상태가 성공적으로 업데이트되었습니다.', + }, + status: status.name, + }; + } + + async patchProfileImage( + userId: number, + fileBuffer: Buffer, + fileType: string + ) { + const imageUrl = await this.s3Service.uploadImage( + userId, + fileBuffer, + fileType + ); + + const user = await this.prisma.user.update({ + where: { id: userId }, + data: { profile_url: imageUrl }, + select: { id: true, nickname: true, profile_url: true }, + }); + return { + message: { + code: 200, + text: '프로필 이미지가 성공적으로 업데이트되었습니다.', + }, + user: { + userId: user.id, + nickname: user.nickname, + profileUrl: user.profile_url, + }, + }; + } + + async patchUserNotification( + userId: number, + notifications: { + pushAlert?: boolean; + followingAlert?: boolean; + projectAlert?: boolean; + } + ) { + // Validation + if ( + notifications.pushAlert === undefined && + notifications.followingAlert === undefined && + notifications.projectAlert === undefined + ) { + return { + message: { + code: 400, + text: '업데이트할 알림 설정이 제공되지 않았습니다.', + }, + }; + } + + const updateData: any = {}; + + if (notifications.pushAlert !== undefined) { + updateData.push_alert = notifications.pushAlert; + } + if (notifications.followingAlert !== undefined) { + updateData.following_alert = notifications.followingAlert; + } + if (notifications.projectAlert !== undefined) { + updateData.project_alert = notifications.projectAlert; + } + + try { + const updatedUser = await this.prisma.user.update({ + where: { id: userId }, + data: updateData, + select: { + push_alert: true, + following_alert: true, + project_alert: true, + }, + }); + + return { + message: { + code: 200, + text: '알림 설정이 성공적으로 업데이트되었습니다.', + }, + notifications: { + pushAlert: updatedUser.push_alert, + followingAlert: updatedUser.following_alert, + projectAlert: updatedUser.project_alert, + }, + }; + } catch (error) { + throw new Error( + `알림 설정 업데이트 중 오류가 발생했습니다: ${error.message}` + ); + } + } + + async addUserSkills(userId: number, skills: string[]) { + // 기존에 없는 스킬만 추가 + const existingSkills = await this.prisma.skill.findMany({ + where: { name: { in: skills } }, + }); + + const existingSkillNames = existingSkills.map(skill => skill.name); + + // 새로운 스킬만 추가 + const newSkills = skills.filter( + skill => !existingSkillNames.includes(skill) + ); + + // 새 스킬 DB에 추가 + const createdSkills = await Promise.all( + newSkills.map(skill => + this.prisma.skill.upsert({ + where: { name: skill }, + update: {}, + create: { name: skill }, + }) + ) + ); + + // User와 Skill 관계 연결 + const skillIds = [...existingSkills, ...createdSkills].map( + skill => skill.id + ); + await Promise.all( + skillIds.map(skillId => + this.prisma.userSkill.upsert({ + where: { user_id_skill_id: { user_id: userId, skill_id: skillId } }, + update: {}, + create: { user_id: userId, skill_id: skillId }, + }) + ) + ); + + return { + message: { + code: 200, + text: '기술 스택이 성공적으로 추가되었습니다', + }, + skills: skills, + }; + } + + async deleteUserSkills(userId: number, skills: string[]) { + // 스킬 ID 가져오기 + const skillRecords = await this.prisma.skill.findMany({ + where: { name: { in: skills } }, + }); + + const skillIds = skillRecords.map(skill => skill.id); + + // User와 Skill 관계 삭제 + await this.prisma.userSkill.deleteMany({ + where: { + user_id: userId, + skill_id: { in: skillIds }, + }, + }); + + return { + message: { + code: 200, + text: '기술 스택이 성공적으로 삭제되었습니다', + }, + skills, + }; + } + + async addUserLink(userId: number, url: string) { + // URL이 이미 존재하는지 확인 + const existingLink = await this.prisma.myPageUserLink.findFirst({ + where: { user_id: userId, link: url }, + }); + + if (existingLink) { + // 이미 존재하는 경우 바로 반환 + const userLinks = await this.prisma.myPageUserLink.findMany({ + where: { user_id: userId }, + select: { id: true, link: true }, + }); + + return { + message: { + code: 201, + text: '이미 존재하는 링크입니다.', + }, + links: userLinks.map(link => ({ + linkId: link.id, + url: link.link, + })), + }; + } + + // 새 링크 추가 + await this.prisma.myPageUserLink.create({ + data: { + user_id: userId, + link: url, + }, + }); + + // 유저의 모든 링크 조회 + const updatedLinks = await this.prisma.myPageUserLink.findMany({ + where: { user_id: userId }, + select: { id: true, link: true }, + }); + + return { + message: { + code: 201, + text: '링크가 성공적으로 추가되었습니다.', + }, + links: updatedLinks.map(link => ({ + linkId: link.id, + url: link.link, + })), + }; + } + + async deleteUserLink(userId: number, id: number) { + const deletedLink = await this.prisma.myPageUserLink.deleteMany({ + where: { + id, + user_id: userId, + }, + }); + + if (deletedLink.count === 0) { + throw new NotFoundException('삭제할 링크를 찾을 수 없습니다.'); + } + + const updatedLinks = await this.prisma.myPageUserLink.findMany({ + where: { user_id: userId }, + select: { id: true, link: true }, + }); + + return { + message: { + code: 200, + text: '링크가 성공적으로 삭제되었습니다.', + }, + links: updatedLinks.map(link => ({ + linkId: link.id, + url: link.link, + })), + }; + } + + async updateUserLink(userId: number, id: number, url: string) { + // 수정할 링크가 존재하는지 확인 + const existingLink = await this.prisma.myPageUserLink.findFirst({ + where: { id, user_id: userId }, + }); + + if (!existingLink) { + throw new NotFoundException('수정할 링크를 찾을 수 없습니다.'); + } + + // URL 업데이트 + await this.prisma.myPageUserLink.update({ + where: { id }, + data: { link: url }, + }); + + // 유저의 모든 링크 조회 + const updatedLinks = await this.prisma.myPageUserLink.findMany({ + where: { user_id: userId }, + select: { id: true, link: true }, + }); + + return { + message: { + code: 200, + text: '링크가 성공적으로 수정되었습니다.', + }, + links: updatedLinks.map(link => ({ + linkId: link.id, + url: link.link, + })), + }; + } + + async deleteAccount(userId: number) { + // 유저가 존재하지 않을 경우 처리 + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user) { + throw new NotFoundException('사용자를 찾을 수 없습니다.'); + } + + // 관련 데이터 삭제 (예: 팔로우, 프로젝트, 댓글 등) + await this.prisma.$transaction([ + this.prisma.follows.deleteMany({ + where: { + OR: [{ following_user_id: userId }, { followed_user_id: userId }], + }, + }), + this.prisma.myPageUserLink.deleteMany({ + where: { user_id: userId }, + }), + this.prisma.projectSave.deleteMany({ + where: { user_id: userId }, + }), + this.prisma.feedLike.deleteMany({ + where: { user_id: userId }, + }), + this.prisma.feedComment.deleteMany({ + where: { user_id: userId }, + }), + this.prisma.feedPost.deleteMany({ + where: { user_id: userId }, + }), + this.prisma.user.delete({ + where: { id: userId }, + }), + ]); + + return { + message: { + code: 200, + text: '사용자와 관련된 모든 데이터가 삭제되었습니다.', + }, + }; + } + + async getUserResume(loggedInUserId: number, targetUserId: number) { + // 지원서와 사용자 정보를 함께 조회 + const resume = await this.prisma.resume.findFirst({ + where: { user_id: targetUserId }, + select: { + title: true, + portfolio_url: true, + detail: true, + id: true, + user: { + select: { + id: true, + nickname: true, + job_detail: true, // 직무 상세 + UserSkills: { + // 기술 스택 + include: { + skill: true, // 기술 이름 + }, + }, + }, + }, + }, + }); + + if (!resume) { + throw new NotFoundException('지원서를 찾을 수 없습니다.'); + } + + // 자기 자신의 프로필인지 확인 + const isOwnProfile = loggedInUserId === targetUserId; + + // 반환 데이터 구성 + return { + message: { + code: 200, + text: '사용자 이력서 조회에 성공했습니다.', + }, + userId: resume.user.id, + resumeId: resume.id, + title: resume.title, + jobDetail: resume.user.job_detail, // 직무 상세 + skills: resume.user.UserSkills.map(userSkill => userSkill.skill.name), // 기술 스택 이름 리스트 + portfolioUrl: resume.portfolio_url, + detail: resume.detail, + isOwnProfile, // 본인 프로필 여부 + }; + } + + // 지원서 생성 + async createUserResume( + userId: number, + data: { title: string; portfolioUrl?: string; detail: string } + ) { + // 유저가 이미 이력서를 가지고 있는지 확인 + const existingResume = await this.prisma.resume.findFirst({ + where: { user_id: userId }, + }); + + if (existingResume) { + // 이미 이력서가 있는 경우 예외를 던짐 + throw new Error('해당 유저는 이미 이력서를 가지고 있습니다.'); + } + + // 이력서 생성 + const newResume = await this.prisma.resume.create({ + data: { + user_id: userId, + title: data.title, + portfolio_url: data.portfolioUrl, + detail: data.detail, + }, + }); + + return { + message: { + code: 201, + text: '사용자 이력서 작성에 성공했습니다.', + }, + resume: { + userId: newResume.user_id, + resumeId: newResume.id, + title: newResume.title, + portfolioUrl: newResume.portfolio_url, + detail: newResume.detail, + }, + }; + } + + // 지원서 수정 + async updateUserResume( + userId: number, + resumeId: number, + data: { title?: string; portfolioUrl?: string; detail?: string } + ) { + // 해당 지원서가 본인의 것인지 확인 + const resume = await this.prisma.resume.findUnique({ + where: { id: resumeId }, + }); + + if (!resume) { + throw new NotFoundException('지원서를 찾을 수 없습니다.'); + } + + if (resume.user_id !== userId) { + throw new ForbiddenException('권한이 없습니다.'); + } + + // 업데이트 로직 + const updatedResume = await this.prisma.resume.update({ + where: { id: resumeId }, + data: { + title: data.title, + portfolio_url: data.portfolioUrl, + detail: data.detail, + }, + }); + + return { + message: { + code: 200, + text: '사용자 이력서 수정에 성공했습니다.', + }, + resume: { + userId: updatedResume.user_id, + resumeId: updatedResume.id, + title: updatedResume.title, + portfolioUrl: updatedResume.portfolio_url, + detail: updatedResume.detail, + }, + }; + } + + // 지원서 삭제 + async deleteUserResume(userId: number, resumeId: number) { + // 해당 지원서가 본인의 것인지 확인 + const resume = await this.prisma.resume.findUnique({ + where: { id: resumeId }, + }); + + if (!resume) { + throw new NotFoundException('지원서를 찾을 수 없습니다.'); + } + + if (resume.user_id !== userId) { + throw new ForbiddenException('권한이 없습니다.'); + } + + // 삭제 로직 + await this.prisma.resume.delete({ + where: { id: resumeId }, + }); + + return { + message: { + code: 200, + text: '사용자 이력서 삭제에 성공했습니다.', + }, + resumeId, + }; + } + + async getFeeds(userId: number, cursor?: number, limit: number = 10) { + // 특정 유저의 피드 조회 (cursor 기반 페이징) + const feeds = await this.prisma.feedPost.findMany({ + where: { user_id: userId }, // 특정 유저의 글만 가져옴 + take: limit, // 가져올 개수 + skip: cursor ? 1 : undefined, // 커서가 있는 경우 첫 번째 항목 제외 + cursor: cursor ? { id: cursor } : undefined, // 커서 적용 + orderBy: { created_at: 'desc' }, + include: { + user: { + select: { + id: true, + nickname: true, + profile_url: true, + }, + }, + Tags: { + include: { + tag: true, + }, + }, + }, + }); + + // 마지막 커서 설정 (다음 요청을 위해) + const lastCursor = feeds.length > 0 ? feeds[feeds.length - 1].id : null; + + return { + message: { + code: 200, + text: + feeds.length > 0 + ? '사용자 피드 조회에 성공했습니다.' + : '더 이상 데이터가 없습니다.', + }, + feeds: feeds.map(feed => ({ + id: feed.id, + title: feed.title, + content: feed.content, + thumbnailUrl: feed.thumbnail_url, + createdAt: feed.created_at, + view: feed.view, + likeCount: feed.like_count, + commentCount: feed.comment_count, + user: { + id: feed.user.id, + nickname: feed.user.nickname, + profileUrl: feed.user.profile_url, + }, + tags: feed.Tags.map(tag => tag.tag.name), + })), + pagination: { + lastCursor, // 다음 요청을 위한 커서 반환 + }, + }; + } + + async getConnectionHubProjects( + userId: number, + type: 'applied' | 'created' = 'created', // 기본값 설정 + cursor?: number, // cursor 추가 + limit: number = 10 // limit 기본값 + ) { + const safeLimit = limit || 10; // Prisma에 전달할 안전한 limit 값 + let projectsQuery; + + if (type === 'applied') { + // 지원한 프로젝트 + projectsQuery = this.prisma.userApplyProject.findMany({ + where: { user_id: userId }, + take: safeLimit, + skip: cursor ? 1 : undefined, // 커서가 있는 경우 첫 번째 항목 제외 + cursor: cursor ? { id: cursor } : undefined, // 커서 설정 + include: { + post: { + include: { + Tags: { select: { tag: { select: { name: true } } } }, + Applications: { select: { id: true } }, + Details: { select: { detail_role: { select: { name: true } } } }, + }, + }, + }, + orderBy: { + post: { created_at: 'desc' }, + }, + }); + } else if (type === 'created') { + // 생성한 프로젝트 + projectsQuery = this.prisma.projectPost.findMany({ + where: { user_id: userId }, + take: safeLimit, + skip: cursor ? 1 : undefined, // 커서가 있는 경우 첫 번째 항목 제외 + cursor: cursor ? { id: cursor } : undefined, // 커서 설정 + include: { + Tags: { select: { tag: { select: { name: true } } } }, + Applications: { select: { id: true } }, + Details: { select: { detail_role: { select: { name: true } } } }, + }, + orderBy: { created_at: 'desc' }, + }); + } else { + throw new BadRequestException('유효하지 않은 타입입니다.'); + } + + const projects = await projectsQuery; + + // 데이터 포맷팅 + const formattedProjects = projects.map(project => { + const projectData = type === 'applied' ? project.post : project; + return { + projectPostId: projectData.id, + title: projectData.title, + content: projectData.content, + thumbnailUrl: projectData.thumbnail_url, + role: projectData.role, + skills: projectData.Tags.map(tag => `${tag.tag.name}`), + detailRoles: projectData.Details.map(d => `${d.detail_role.name}`), + hubType: projectData.hub_type, + startDate: projectData.start_date.toISOString().split('T')[0], + duration: projectData.duration, + workType: projectData.work_type, + applyCount: projectData.Applications.length, + bookMarkCount: projectData.saved_count, + viewCount: projectData.view, + status: projectData.recruiting ? 'OPEN' : 'CLOSED', + createdAt: projectData.created_at, + }; + }); + + const lastCursor = projects[projects.length - 1]?.id || null; // 마지막 커서 설정 + + return { + message: { + code: 200, + text: + formattedProjects.length > 0 + ? '프로젝트 조회 성공' + : '더 이상 데이터가 없습니다.', + }, + projects: formattedProjects, + pagination: { + lastCursor, + }, + }; + } + + async addArtistWork(userId: number, musicUrl: string) { + const addMusicUrl = musicUrl; + + if (!addMusicUrl) { + throw new BadRequestException('음악 URL이 필요합니다.'); + } + + const newWork = await this.prisma.artistData.create({ + data: { + user_id: userId, + music_url: addMusicUrl, + }, + }); + + return { + message: { + code: 201, + text: '아티스트 작업물 추가에 성공했습니다.', + }, + musicId: newWork.id, + musicUrl: newWork.music_url, + }; + } + + async updateArtistWork(userId: number, workId: number, musicUrl: string) { + const newMusicUrl = musicUrl; + + if (!newMusicUrl) { + throw new BadRequestException('음악 URL이 필요합니다.'); + } + + const existingWork = await this.prisma.artistData.findFirst({ + where: { + id: workId, + user_id: userId, + }, + }); + + if (!existingWork) { + throw new NotFoundException('작업물을 찾을 수 없습니다.'); + } + + const updatedWork = await this.prisma.artistData.update({ + where: { id: workId }, + data: { + music_url: newMusicUrl, + }, + }); + + return { + message: { + code: 200, + text: '아티스트 작업물 수정에 성공했습니다.', + }, + musicId: updatedWork.id, + musicUrl: updatedWork.music_url, + }; + } + + async deleteArtistWork(userId: number, workId: number) { + const existingWork = await this.prisma.artistData.findFirst({ + where: { + id: workId, + user_id: userId, + }, + }); + + if (!existingWork) { + throw new NotFoundException('작업물을 찾을 수 없습니다.'); + } + + await this.prisma.artistData.delete({ + where: { id: workId }, + }); + + return { + message: { + code: 200, + text: '아티스트 작업물 삭제에 성공했습니다.', + }, + musicId: workId, + }; + } + + async updateGithubUsername(userId: number, githubUsername: string) { + if (!githubUsername) { + throw new NotFoundException('깃허브 닉네임이 필요합니다.'); + } + + // upsert를 사용하여 데이터 생성 또는 업데이트 + const updatedData = await this.prisma.programmerData.upsert({ + where: { user_id: userId }, + update: { + github_username: githubUsername, // 이미 존재하는 경우 업데이트 + }, + create: { + user_id: userId, + github_username: githubUsername, // 존재하지 않는 경우 새로 생성 + }, + }); + + return { + message: { + code: 200, + text: '깃허브 유저네임이 성공적으로 저장되었습니다.', + }, + githubUsername: updatedData.github_username, + }; + } } diff --git a/src/s3/s3.service.ts b/src/s3/s3.service.ts index 1b0775f..9488740 100644 --- a/src/s3/s3.service.ts +++ b/src/s3/s3.service.ts @@ -15,10 +15,12 @@ export class S3Service { async uploadImage( userId: number, fileBuffer: Buffer, - fileType: string + fileType: string, + prefix?: string ): Promise { try { - const fileName = `mypli_users/profile_${crypto.randomUUID()}.${fileType}`; + const filePrefix = prefix || 'pad_users/profile'; + const fileName = `${filePrefix}_${crypto.randomUUID()}.${fileType}`; const uploadResult = await this.s3 .upload({ Bucket: this.bucketName, diff --git a/src/search/search.controller.ts b/src/search/search.controller.ts new file mode 100644 index 0000000..4c3cc18 --- /dev/null +++ b/src/search/search.controller.ts @@ -0,0 +1,53 @@ +import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common'; +import { SearchService } from './search.service'; +import { OptionalAuthGuard } from '@src/modules/auth/guards/optional-auth.guard'; + +@Controller('search') +export class SearchController { + constructor(private readonly searchService: SearchService) {} + + // 모달 창에서 검색 + @Get('modal') + @UseGuards(OptionalAuthGuard) + async handleModalSearch( + @Req() req, + @Query('keyword') keyword: string, + @Query('category') category: string + ) { + return await this.searchService.handleModalSearch( + req.user, + keyword, + category + ); + } + + // 피드 페이지에서 검색 + @Get('feed') + @UseGuards(OptionalAuthGuard) + async handleFeedSearch( + @Req() req, + @Query('keyword') keyword: string, + @Query('cursor') cursor: number + ) { + return await this.searchService.handleFeedPageSearch( + req.user, + keyword, + cursor + ); + } + + // 커넥션허브 페이지에서 검색 + @Get('connectionhub') + @UseGuards(OptionalAuthGuard) + async handleConnectionhubSearch( + @Req() req, + @Query('keyword') keyword: string, + @Query('cursor') cursor: number + ) { + return await this.searchService.handleConnectionhubSearch( + req.user, + keyword, + cursor + ); + } +} diff --git a/src/search/search.module.ts b/src/search/search.module.ts new file mode 100644 index 0000000..d3004eb --- /dev/null +++ b/src/search/search.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { SearchService } from './search.service'; +import { SearchController } from './search.controller'; +import { PrismaModule } from '@src/prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [SearchController], + providers: [SearchService], +}) +export class SearchModule {} diff --git a/src/search/search.service.ts b/src/search/search.service.ts new file mode 100644 index 0000000..6a8a0e6 --- /dev/null +++ b/src/search/search.service.ts @@ -0,0 +1,260 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '@src/prisma/prisma.service'; + +@Injectable() +export class SearchService { + constructor(private readonly prisma: PrismaService) {} + // 모달 검색 핸들러 + async handleModalSearch(user, keyword: string, category: string) { + const userId = user ? user.user_id : 0; + + let result; + const limit = 4; + + // category 값에 따라 응답데이터 변경 + switch (category) { + case 'all': + result = { + feedResult: await this.feedResultModal( + await this.searchFeed(userId, keyword, limit, 0) + ), + + projectResult: await this.connectionhubResultModal( + await this.searchConnectionhub(userId, keyword, limit, 0) + ), + }; + break; + case 'feed': + result = { + feedResult: await this.feedResultModal( + await this.searchFeed(userId, keyword, limit, 0) + ), + projectResult: { projects: [], hasMore: false }, + }; + break; + case 'connectionhub': + result = { + feedResult: { feeds: [], hasMore: false }, + projectResult: await this.connectionhubResultModal( + await this.searchConnectionhub(userId, keyword, limit, 0) + ), + }; + } + result.messgae = { code: 200, text: '검색 결과 조회에 성공했습니다.' }; + return result; + } + + // 피드 페이지 검색 핸들러 + async handleFeedPageSearch(user, keyword: string, cursor: number) { + const userId = user ? user.user_id : 0; + const limit = 10; + const posts = await this.feedResultPage( + await this.searchFeed(userId, keyword, limit, cursor) + ); + const lastCursor = posts[posts.length - 1]?.postId || null; + return { + posts, + pagination: { lastCursor }, + message: { code: 200, text: '피드 검색 결과 조회에 성공했습니다.' }, + }; + } + + // 커넥션허브 페이지 검색 핸들러 + async handleConnectionhubSearch(user, keyword: string, cursor: number) { + const userId = user ? user.user_id : 0; + const limit = 10; + + const projects = await this.connectionhubResultPage( + await this.searchConnectionhub(userId, keyword, limit, cursor) + ); + + const lastCursor = projects[projects.length - 1]?.projectId || null; + return { + projects, + pagination: { lastCursor }, + message: { code: 200, text: '커넥션허브 검색 결과 조회에 성공했습니다.' }, + }; + } + + // 피드 검색결과 조회 + async searchFeed( + userId: number, + keyword: string, + limit: number, + cursor: number + ) { + const result = await this.prisma.feedPost.findMany({ + where: { + ...(cursor ? { id: { lt: cursor } } : {}), + OR: [ + { title: { contains: keyword } }, + { content: { contains: keyword } }, + ], + }, + orderBy: { id: 'desc' }, + take: limit + 1, + include: { + user: { + select: { + id: true, + name: true, + nickname: true, + profile_url: true, + role: { select: { name: true } }, + }, + }, + Tags: { select: { tag: { select: { name: true } } } }, + Likes: { where: { user_id: userId } }, + }, + }); + return result; + } + + // 모달 피드 검색결과 데이터 + async feedResultModal(result) { + const hasMore = result.length == 5; + if (hasMore) result = result.slice(0, 4); + if (!result.length) return { feeds: [], hasMore }; + const feeds = result.map(res => ({ + userId: res.user.id, + userName: res.user.name, + userNickname: res.user.nickname, + userProfileUrl: res.user.profile_url, + userRole: res.user.role.name, + feedId: res.id, + title: res.title, + tags: res.Tags.map(v => v.tag.name), + createdAt: res.created_at, + })); + + return { feeds, hasMore }; + } + + // 페이지 피드 검색결과 데이터 + async feedResultPage(result) { + const feeds = result.map(result => ({ + userId: result.user.id, + userName: result.user.name, + userNickname: result.user.nickname, + userRole: result.user.role.name, + userProfileUrl: result.user.profile_url, + title: result.title, + postId: result.id, + thumnailUrl: result.thumbnail_url, + content: result.content, + tags: result.Tags.map(v => v.tag.name), + commentCount: result.comment_count, + likeCount: result.like_count, + viewCount: result.view, + createdAt: result.created_at, + isLiked: !!result.Likes.length, + })); + + return feeds; + } + + // 커넥션허브 검색결과 조회 + async searchConnectionhub( + userId: number, + keyword: string, + limit: number, + cursor: number + ) { + const result = await this.prisma.projectPost.findMany({ + where: { + ...(cursor ? { id: { lt: cursor } } : {}), + OR: [ + { title: { contains: keyword } }, + { content: { contains: keyword } }, + { + Tags: { + some: { + tag: { name: { contains: keyword } }, + }, + }, + }, + { + Details: { + some: { + detail_role: { + name: { contains: keyword }, + }, + }, + }, + }, + ], + }, + orderBy: { id: 'desc' }, + take: limit + 1, + include: { + Tags: { select: { tag: { select: { name: true } } } }, + Details: { select: { detail_role: { select: { name: true } } } }, + user: { + select: { + id: true, + name: true, + nickname: true, + profile_url: true, + role: { select: { name: true } }, + }, + }, + Saves: { where: { user_id: userId } }, + }, + }); + + return result; + } + + // 모달 커넥션허브 검색결과 데이터 + async connectionhubResultModal(result) { + const hasMore = result.length == 5; + if (hasMore) result = result.slice(0, 4); + if (!result.length) return { projects: [], hasMore }; + const projects = result.map(res => ({ + userId: res.user.id, + userName: res.user.name, + userNickname: res.user.nickname, + userProfileUrl: res.user.profile_url, + userRole: res.user.role.name, + projectId: res.id, + title: res.title, + role: res.role, + detailRoles: res.Details.map(v => v.detail_role.name), + skills: res.Tags.map(v => v.tag.name), + startDate: res.start_date, + duration: res.duration, + hubType: res.hub_type, + workType: res.work_type, + })); + + return { projects, hasMore }; + } + + // 페이지 커넥션허브 검색결과 데이터 + async connectionhubResultPage(result) { + const projects = result.map(res => ({ + userId: res.user.id, + userName: res.user.name, + userNickname: res.user.nickname, + userProfileUrl: res.user.profile_url, + userRole: res.user.role.name, + projectId: res.id, + title: res.title, + content: res.content, + thumbnailUrl: res.thumbnail_url, + role: res.role, + skills: res.Tags.map(tag => `${tag.tag.name}`), + detailRoles: res.Details.map(d => `${d.detail_role.name}`), + hubType: res.hub_type, + startDate: res.start_date.toISOString().split('T')[0], + duration: res.duration, + workType: res.work_type, + applyCount: res.applicant_count, + bookMarkCount: res.saved_count, + viewCount: res.view + 1, + status: res.recruiting ? 'OPEN' : 'CLOSED', + isMarked: res.Saves.length, + })); + return projects; + } +}