diff --git a/Chapter_mission/index.js b/Chapter_mission/index.js deleted file mode 100644 index a7ec5da..0000000 --- a/Chapter_mission/index.js +++ /dev/null @@ -1,95 +0,0 @@ -import dotenv from "dotenv"; -import express from "express"; -import cors from "cors"; - -import { handleUserSignUp } from "./src/controllers/user.controller.js"; -import { handleAddStore } from "./src/controllers/store.controller.js"; -import { handleAddReview, - handleListUserReviews, - handleListStoreReviews, } from "./src/controllers/review.controller.js"; -import { handleAddMission, - handleListMissionsByStore, } from "./src/controllers/mission.controller.js"; -import { handleChallengeMission } from "./src/controllers/userMission.controller.js"; -import morgan from "morgan"; -import cookieParser from "cookie-parser"; - -dotenv.config(); - -const app = express(); -const port = process.env.PORT; - -/** - * 공통 응답을 사용할 수 있는 헬퍼 함수 등록 - */ -app.use((req, res, next) => { - res.success = (success) => { - return res.json({ resultType: "SUCCESS", error: null, success }); - }; - - res.errored = ({ errorCode = "unknown", reason = null, data = null }) => { - return res.json({ - resultType: "FAIL", - error: { errorCode, reason, data }, - success: null, - }); - }; - - next(); -}); - -/** -*전역 미들웨어 등록 -*/ -app.use(cors()); // cors 방식 허용 -app.use(express.static('public')); // 정적 파일 접근 -app.use(express.json()); // request의 본문을 json으로 해석할 수 있도록 함 (JSON 형태의 요청 body를 파싱하기 위함) -app.use(express.urlencoded({ extended: false })); // 단순 객체 문자열 형태로 본문 데이터 해석 -app.use(morgan('dev')); -app.use(cookieParser()); - -app.get("/", (req, res) => { - res.send("Hello World!"); -}); - -/** - * 라우터 설정 - */ -// 사용자 -app.post("/api/v1/users/signup", handleUserSignUp); -app.get("/api/v1/users/:user_id/reviews", handleListUserReviews); -app.get("/api/v1/users/:user_id/missions", handleListActiveMissions); - -// 지역 및 가게 -app.post("/api/v1/regions/:region_id/stores", handleAddStore); -app.get("/api/v1/stores/:store_id/missions", handleListMissionsByStore); - -// 리뷰 -app.post("/api/v1/stores/:store_id/reviews", handleAddReview); -app.get("/api/v1/stores/:store_id/reviews", handleListStoreReviews); - -// 미션 -app.post("/api/v1/stores/:store_id/missions", handleAddMission); -app.post("/api/v1/missions/:mission_id/challenges", handleChallengeMission); -app.patch( - "/api/v1/user-missions/:userMissionId/complete", - handleCompleteMission -); - -/** - * 전역 오류를 처리하기 위한 미들웨어 - */ -app.use((err, req, res, next) => { - if (res.headersSent) { - return next(err); - } - - res.status(err.statusCode || 500).error({ - errorCode: err.errorCode || "unknown", - reason: err.reason || err.message || null, - data: err.data || null, - }); -}); - -app.listen(port, () => { - console.log(`Example app listening on port ${port}`); -}); \ No newline at end of file diff --git a/Chapter_mission/package-lock.json b/Chapter_mission/package-lock.json index e3d55fb..b125e81 100644 --- a/Chapter_mission/package-lock.json +++ b/Chapter_mission/package-lock.json @@ -1,11 +1,11 @@ { - "name": "chapter6_mission", + "name": "chapter_mission", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "chapter6_mission", + "name": "chapter_mission", "version": "1.0.0", "dependencies": { "@prisma/client": "^6.18.0", @@ -15,8 +15,15 @@ "dotenv": "^17.2.3", "express": "^5.1.0", "http-status-codes": "^2.3.0", + "jsonwebtoken": "^9.0.2", "morgan": "^1.10.1", - "prisma": "^6.18.0" + "mysql2": "^3.15.3", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", + "passport-jwt": "^4.0.1", + "prisma": "^6.18.0", + "swagger-autogen": "^2.23.7", + "swagger-ui-express": "^5.0.1" } }, "node_modules/@prisma/client": { @@ -98,6 +105,13 @@ "@prisma/debug": "6.18.0" } }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@standard-schema/spec": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", @@ -117,6 +131,42 @@ "node": ">= 0.6" } }, + "node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/basic-auth": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", @@ -169,6 +219,22 @@ "node": ">=18" } }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -271,6 +337,12 @@ "consola": "^3.2.3" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, "node_modules/confbox": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", @@ -374,6 +446,15 @@ } } }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deepmerge-ts": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", @@ -389,6 +470,15 @@ "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", "license": "MIT" }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -430,6 +520,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -614,6 +713,12 @@ "node": ">= 0.8" } }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -623,6 +728,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -677,6 +791,27 @@ "giget": "dist/cli.mjs" } }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -756,6 +891,17 @@ "node": ">=0.10.0" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -777,6 +923,12 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -786,6 +938,133 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/lru.min": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.3.tgz", + "integrity": "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -837,6 +1116,18 @@ "node": ">= 0.6" } }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/morgan": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", @@ -886,6 +1177,54 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mysql2": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", + "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.0", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz", + "integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==", + "license": "MIT", + "dependencies": { + "lru-cache": "^7.14.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -940,6 +1279,12 @@ "node": "^14.16.0 || >=16.10.0" } }, + "node_modules/oauth": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", + "integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -1006,6 +1351,83 @@ "node": ">= 0.8" } }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-google-oauth20": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", + "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", + "license": "MIT", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", + "license": "MIT", + "dependencies": { + "jsonwebtoken": "^9.0.0", + "passport-strategy": "^1.0.0" + } + }, + "node_modules/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", + "license": "MIT", + "dependencies": { + "base64url": "3.x.x", + "oauth": "0.10.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-to-regexp": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", @@ -1022,6 +1444,11 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", @@ -1213,6 +1640,18 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", @@ -1235,6 +1674,11 @@ "node": ">= 18" } }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + }, "node_modules/serve-static": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", @@ -1328,6 +1772,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -1337,6 +1790,42 @@ "node": ">= 0.8" } }, + "node_modules/swagger-autogen": { + "version": "2.23.7", + "resolved": "https://registry.npmjs.org/swagger-autogen/-/swagger-autogen-2.23.7.tgz", + "integrity": "sha512-vr7uRmuV0DCxWc0wokLJAwX3GwQFJ0jwN+AWk0hKxre2EZwusnkGSGdVFd82u7fQLgwSTnbWkxUL7HXuz5LTZQ==", + "license": "MIT", + "dependencies": { + "acorn": "^7.4.1", + "deepmerge": "^4.2.2", + "glob": "^7.1.7", + "json5": "^2.2.3" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.30.2", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.30.2.tgz", + "integrity": "sha512-HWCg1DTNE/Nmapt+0m2EPXFwNKNeKK4PwMjkwveN/zn1cV2Kxi9SURd+m0SpdcSgWEK/O64sf8bzXdtUhigtHA==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/tinyexec": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz", @@ -1366,6 +1855,12 @@ "node": ">= 0.6" } }, + "node_modules/uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==", + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -1375,6 +1870,15 @@ "node": ">= 0.8" } }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/Chapter_mission/package.json b/Chapter_mission/package.json index 95d4d7c..e380f67 100644 --- a/Chapter_mission/package.json +++ b/Chapter_mission/package.json @@ -1,5 +1,5 @@ { - "name": "chapter6_mission", + "name": "chapter_mission", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "start": "node src/index.js", @@ -15,7 +15,14 @@ "dotenv": "^17.2.3", "express": "^5.1.0", "http-status-codes": "^2.3.0", + "jsonwebtoken": "^9.0.2", "morgan": "^1.10.1", - "prisma": "^6.18.0" + "mysql2": "^3.15.3", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", + "passport-jwt": "^4.0.1", + "prisma": "^6.18.0", + "swagger-autogen": "^2.23.7", + "swagger-ui-express": "^5.0.1" } } diff --git a/Chapter_mission/src/auth.config.js b/Chapter_mission/src/auth.config.js new file mode 100644 index 0000000..a49ce0a --- /dev/null +++ b/Chapter_mission/src/auth.config.js @@ -0,0 +1,115 @@ +import dotenv from "dotenv"; +import { Strategy as GoogleStrategy } from "passport-google-oauth20"; +import { prisma } from "./db.config.js"; +import jwt from "jsonwebtoken"; // JWT 생성을 위해 import +import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt'; + +dotenv.config(); +const secret = process.env.JWT_SECRET; // .env의 비밀 키 + +export const generateAccessToken = (user) => { + return jwt.sign( + { id: String(user.id), email: user.email }, + secret, + { expiresIn: '1h' } + ); +}; + +export const generateRefreshToken = (user) => { + return jwt.sign( + { id: String(user.id) }, + secret, + { expiresIn: '14d' } + ); +}; + +// GoogleVerify +const googleVerify = async (profile) => { + const email = profile.emails?.[0]?.value; + const name = profile.displayName || null; + const profileImage = profile.photos?.[0]?.value || null; + + if (!email) { + throw new Error(`profile.email was not found: ${profile}`); + } + + const foundUser = await prisma.user.findFirst({ where: { email } }); + if (foundUser) { + return { id: String(foundUser.id), email, name: foundUser.name }; + } + + const createdUser = await prisma.user.create({ + data: { + email, + password: "GOOGLE_OAUTH_USER", + name: name, + nickname: `google_${Date.now()}`, // 임시 닉네임 + profileImage: profileImage, + // 선택 항목 일단 null + birth: null, + phoneNumber: null, + gender: "UNKNOWN", + inactiveDate: null, + }, + }); + + return { id: String(createdUser.id), email: createdUser.email, name: createdUser.name }; +} + + +// GoogleStrategy + +export const googleStrategy = new GoogleStrategy( + { + clientID: process.env.PASSPORT_GOOGLE_CLIENT_ID, + clientSecret: process.env.PASSPORT_GOOGLE_CLIENT_SECRET, + callbackURL: "/oauth2/callback/google", + scope: ["email", "profile"], + }, + + + async (accessToken, refreshToken, profile, cb) => { + try { + + const user = await googleVerify(profile); + + const jwtAccessToken = generateAccessToken(user); + const jwtRefreshToken = generateRefreshToken(user); + + return cb(null, { + accessToken: jwtAccessToken, + refreshToken: jwtRefreshToken, + }); + + } catch (err) { + return cb(err); + } + } +); + +const jwtOptions = { + // 요청 헤더의 'Authorization'에서 'Bearer ' 토큰을 추출 + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: process.env.JWT_SECRET, +}; + +export const jwtStrategy = new JwtStrategy(jwtOptions, async (payload, done) => { + try { + + const userId = BigInt(payload.id); + + const user = await prisma.user.findUnique({ where: { id: userId } }); + + if (!user) return done(null, false); + + const safeUser = { + id: String(user.id), + email: user.email, + name: user.name, + }; + + return done(null, safeUser); + } catch (err) { + return done(err, false); + } +}); \ No newline at end of file diff --git a/Chapter_mission/src/controllers/mission.controller.js b/Chapter_mission/src/controllers/mission.controller.js index 910b3f1..5cd0a08 100644 --- a/Chapter_mission/src/controllers/mission.controller.js +++ b/Chapter_mission/src/controllers/mission.controller.js @@ -1,12 +1,148 @@ // src/controllers/mission.controller.js import { StatusCodes } from "http-status-codes"; import { bodyToMission } from "../dtos/mission.dto.js"; -import { createMission } from "../services/mission.service.js"; -import { listMissionsByStore } from "../services/mission.service.js"; +import { createMission, listMissionsByStore } from "../services/mission.service.js"; // 미션 등록 export const handleAddMission = async (req, res, next) => { - const { storeId } = req.params; + /* + #swagger.tags = ['Missions'] + #swagger.summary = '미션 등록' + #swagger.description = '특정 가게(store_id)에 사용자가 수행할 수 있는 미션을 등록합니다.' + + #swagger.parameters['store_id'] = { + in: 'path', + required: true, + schema: { + type: 'integer' + }, + description: '미션을 등록할 가게의 ID' + } + + #swagger.requestBody = { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + title: { + type: "string", + description: "미션 제목(필수)" + }, + description: { + type: "string", + description: "미션 설명(선택)" + }, + point: { + type: "integer", + description: "미션 완료 시 지급할 포인트(0 이상 정수)" + }, + deadline: { + type: "string", + format: "date-time", + description: "마감 기한(선택, ISO 8601 형식)" + } + }, + required: ["title", "point"], + example: { + title: "인증샷과 함께 리뷰 남기기", + description: "가게에서 음식 사진과 함께 리뷰를 작성하면 포인트 지급", + point: 100, + deadline: "2025-12-31T23:59:59.000Z" + } + } + } + } + } + + #swagger.responses[201] = { + description: '미션 등록 성공', + content: { + "application/json": { + schema: { + $ref: '#/components/schemas/SuccessResponse' + }, + example: { + resultType: "SUCCESS", + error: null, + success: { + id: 7, + title: "인증샷과 함께 리뷰 남기기", + description: "가게에서 음식 사진과 함께 리뷰를 작성하면 포인트 지급", + point: 100, + deadline: "2025-12-31T23:59:59.000Z", + createdAt: "2025-01-15T12:00:00.000Z", + updatedAt: "2025-01-15T12:00:00.000Z", + storeId: 3 + } + } + } + } + } + + #swagger.responses[400] = { + description: '잘못된 요청 (필수 필드 누락 등)', + content: { + "application/json": { + schema: { + $ref: '#/components/schemas/ErrorResponse' + }, + example: { + resultType: "FAIL", + error: { + errorCode: "unknown", + reason: "요청 형식이 올바르지 않습니다. (예: title 누락, point 타입 오류 등)", + data: null + }, + success: null + } + } + } + } + + #swagger.responses[404] = { + description: '해당 store_id에 해당하는 가게가 존재하지 않는 경우', + content: { + "application/json": { + schema: { + $ref: '#/components/schemas/ErrorResponse' + }, + example: { + resultType: "FAIL", + error: { + errorCode: "U006", + reason: "해당 가게(store_id)를 찾을 수 없습니다.", + data: { storeId: 3 } + }, + success: null + } + } + } + } + + #swagger.responses[500] = { + description: '서버 내부 오류', + content: { + "application/json": { + schema: { + $ref: '#/components/schemas/ErrorResponse' + }, + example: { + resultType: "FAIL", + error: { + errorCode: "unknown", + reason: "서버 내부 오류가 발생했습니다.", + data: null + }, + success: null + } + } + } + } + */ + + const storeId = Number(req.params.store_id); console.log("미션 등록 요청:", req.body); @@ -22,8 +158,98 @@ export const handleAddMission = async (req, res, next) => { // 특정 가게의 미션 목록 조회 export const handleListMissionsByStore = async (req, res, next) => { + /* + #swagger.tags = ['Missions'] + #swagger.summary = '특정 가게의 미션 목록 조회' + #swagger.description = '가게(store_id)에 등록된 모든 미션 목록을 조회합니다.' + + #swagger.parameters['store_id'] = { + in: 'path', + required: true, + schema: { + type: 'integer' + }, + description: '미션을 조회할 가게의 ID' + } + + #swagger.responses[200] = { + description: '미션 목록 조회 성공', + content: { + "application/json": { + schema: { + $ref: '#/components/schemas/SuccessResponse' + }, + example: { + resultType: "SUCCESS", + error: null, + success: [ + { + id: 7, + title: "인증샷과 함께 리뷰 남기기", + description: "가게에서 음식 사진과 함께 리뷰를 작성하면 포인트 지급", + point: 100, + deadline: "2025-12-31T23:59:59.000Z", + createdAt: "2025-01-15T12:00:00.000Z", + updatedAt: "2025-01-15T12:00:00.000Z" + }, + { + id: 8, + title: "친구와 함께 방문하기", + description: "2인 이상 방문 인증 시 포인트 지급", + point: 150, + deadline: null, + createdAt: "2025-01-16T12:00:00.000Z", + updatedAt: "2025-01-16T12:00:00.000Z" + } + ] + } + } + } + } + + #swagger.responses[404] = { + description: '해당 store_id에 대한 미션이 없거나 가게가 존재하지 않는 경우', + content: { + "application/json": { + schema: { + $ref: '#/components/schemas/ErrorResponse' + }, + example: { + resultType: "FAIL", + error: { + errorCode: "unknown", + reason: "해당 store_id에 대한 미션이 없거나 가게가 존재하지 않습니다.", + data: null + }, + success: null + } + } + } + } + + #swagger.responses[500] = { + description: '서버 내부 오류', + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/ErrorResponse" + }, + example: { + resultType: "FAIL", + error: { + errorCode: "unknown", + reason: "서버 내부 오류가 발생했습니다.", + data: null + }, + success: null + } + } + } + } + */ + try { - const storeId = parseInt(req.params.storeId); + const storeId = Number(req.params.store_id); const missions = await listMissionsByStore(storeId); @@ -31,4 +257,4 @@ export const handleListMissionsByStore = async (req, res, next) => { } catch (error) { next(error); } -}; \ No newline at end of file +}; diff --git a/Chapter_mission/src/controllers/review.controller.js b/Chapter_mission/src/controllers/review.controller.js index 534cdec..5b04dc5 100644 --- a/Chapter_mission/src/controllers/review.controller.js +++ b/Chapter_mission/src/controllers/review.controller.js @@ -1,13 +1,171 @@ // src/controllers/review.controller.js import { StatusCodes } from "http-status-codes"; import { bodyToReview } from "../dtos/review.dto.js"; -import { addReview } from "../services/review.service.js"; -import { listStoreReviews } from "../services/review.service.js"; -import { listUserReviews } from "../services/review.service.js"; +import { addReview, listStoreReviews, listUserReviews } from "../services/review.service.js"; // 리뷰 등록 요청 export const handleAddReview = async (req, res, next) => { - const { storeId } = req.params; + /* + #swagger.tags = ['Reviews'] + #swagger.summary = '리뷰 등록' + #swagger.description = '특정 가게(store_id)와 유저 미션(userMissionId)으로 리뷰를 등록합니다.' + + #swagger.parameters['store_id'] = { + in: 'path', + required: true, + schema: { + type: 'integer' + }, + description: '리뷰를 등록할 가게의 ID' + } + + #swagger.requestBody = { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + userMissionId: { type: "integer" }, + body: { type: "string" }, + score: { type: "integer" }, + imageCount: { type: "integer" } + }, + required: ["userMissionId", "body", "score"], + example: { + userMissionId: 10, + body: "정말 맛있어요!! 또 올게요.", + score: 5, + imageCount: 1 + } + } + } + } + } + + #swagger.responses[201] = { + description: '리뷰 등록 성공', + content: { + "application/json": { + schema: { + $ref: '#/components/schemas/SuccessResponse' + }, + example: { + resultType: "SUCCESS", + error: null, + success: { + id: 1, + userMissionId: 10, + body: "정말 맛있어요!! 또 올게요.", + score: 5, + imageCount: 1, + createdAt: "2025-01-15T12:00:00.000Z", + updatedAt: "2025-01-15T12:00:00.000Z" + } + } + } + } + } + + #swagger.responses[400] = { + description: '잘못된 요청 (필수 필드 누락, score 범위 오류 등)', + content: { + "application/json": { + schema: { + $ref: '#/components/schemas/ErrorResponse' + }, + example: { + resultType: "FAIL", + error: { + errorCode: "unknown", + reason: "요청 형식이 올바르지 않습니다. (예: userMissionId 누락, score 범위 오류 등)", + data: null + }, + success: null + } + } + } + } + + #swagger.responses[404] = { + description: '가게(store_id) 또는 유저 미션(userMissionId)을 찾을 수 없는 경우', + content: { + "application/json": { + schema: { + $ref: '#/components/schemas/ErrorResponse' + }, + examples: { + StoreNotFound: { + summary: "가게가 존재하지 않는 경우", + value: { + resultType: "FAIL", + error: { + errorCode: "U006", + reason: "존재하지 않는 가게입니다.", + data: { storeId: 3 } + }, + success: null + } + }, + MissionNotFound: { + summary: "유저 미션이 존재하지 않는 경우", + value: { + resultType: "FAIL", + error: { + errorCode: "U004", + reason: "미션 정보를 찾을 수 없습니다.", + data: { userMissionId: 10 } + }, + success: null + } + } + } + } + } + } + + #swagger.responses[409] = { + description: '이미 해당 미션에 대한 리뷰가 존재하는 경우 (중복 리뷰)', + content: { + "application/json": { + schema: { + $ref: '#/components/schemas/ErrorResponse' + }, + example: { + resultType: "FAIL", + error: { + errorCode: "U005", + reason: "이미 리뷰를 작성한 미션입니다.", + data: { userMissionId: 10 } + }, + success: null + } + } + } + } + + #swagger.responses[500] = { + description: '서버 내부 오류', + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/ErrorResponse" + }, + example: { + resultType: "FAIL", + error: { + errorCode: "unknown", + reason: "서버 내부 오류가 발생했습니다.", + data: null + }, + success: null + } + } + } + } + */ + + const storeId = Number(req.params.store_id); console.log("리뷰 등록 요청:", req.body); @@ -23,8 +181,110 @@ export const handleAddReview = async (req, res, next) => { // 특정 가게의 리뷰 목록 조회 export const handleListStoreReviews = async (req, res, next) => { + /* + #swagger.tags = ['Reviews'] + #swagger.summary = '특정 가게의 리뷰 목록 조회' + #swagger.description = '가게(store_id)에 작성된 리뷰 목록을 페이징(cursor 기반)으로 조회합니다.' + + #swagger.parameters['store_id'] = { + in: 'path', + required: true, + schema: { + type: 'integer' + }, + description: '리뷰를 조회할 가게의 ID' + } + + #swagger.parameters['cursor'] = { + in: 'query', + required: false, + schema: { + type: 'integer' + }, + description: '마지막으로 조회한 리뷰 ID (cursor 기반 페이징)' + } + + #swagger.responses[200] = { + description: '리뷰 목록 조회 성공', + content: { + "application/json": { + schema: { + $ref: '#/components/schemas/SuccessResponse' + }, + example: { + resultType: "SUCCESS", + error: null, + success: { + data: [ + { + id: 1, + nickname: "워니", + profileImage: "https://example.com/profile.png", + score: 5, + body: "정말 맛있어요!", + createdAt: "2025-01-15T12:00:00.000Z" + }, + { + id: 2, + nickname: "길동", + profileImage: null, + score: 4, + body: "괜찮았어요.", + createdAt: "2025-01-16T09:30:00.000Z" + } + ], + pagination: { + cursor: 2 + } + } + } + } + } + } + + #swagger.responses[404] = { + description: '해당 store_id에 대한 가게가 존재하지 않는 경우', + content: { + "application/json": { + schema: { + $ref: '#/components/schemas/ErrorResponse' + }, + example: { + resultType: "FAIL", + error: { + errorCode: "U006", + reason: "해당 가게(store_id)를 찾을 수 없습니다.", + data: { storeId: 3 } + }, + success: null + } + } + } + } + + #swagger.responses[500] = { + description: '서버 내부 오류', + content: { + "application/json": { + schema: { + $ref: '#/components/schemas/ErrorResponse' + }, + example: { + resultType: "FAIL", + error: { + errorCode: "unknown", + reason: "서버 내부 오류가 발생했습니다.", + data: null + }, + success: null + } + } + } + } + */ + try { - const storeId = parseInt(req.params.storeId); + const storeId = parseInt(req.params.store_id); const cursor = req.query.cursor ? parseInt(req.query.cursor) : 0; const result = await listStoreReviews(storeId, cursor); @@ -37,7 +297,92 @@ export const handleListStoreReviews = async (req, res, next) => { // 내가 작성한 리뷰 목록 조회 export const handleListUserReviews = async (req, res, next) => { - const userId = parseInt(req.params.userId); + /* + #swagger.tags = ['Reviews'] + #swagger.summary = '내가 작성한 리뷰 목록 조회' + #swagger.description = '특정 사용자(user_id)가 작성한 리뷰 목록을 페이징(cursor 기반)으로 조회합니다.' + + #swagger.parameters['user_id'] = { + in: 'path', + required: true, + schema: { + type: 'integer' + }, + description: '리뷰를 조회할 사용자 ID' + } + + #swagger.parameters['cursor'] = { + in: 'query', + required: false, + schema: { + type: 'integer' + }, + description: '마지막으로 조회한 리뷰 ID (cursor 기반 페이징)' + } + + #swagger.responses[200] = { + description: '내 리뷰 목록 조회 성공', + content: { + "application/json" : { + schema: { + $ref: '#/components/schemas/SuccessResponse' + }, + example: { + resultType: "SUCCESS", + error: null, + success: { + data: [ + { + id: 1, + storeName: "홍대 떡볶이", + body: "여기 떡볶이 최고", + score: 5, + imageCount: 2, + createdAt: "2025-01-15T12:00:00.000Z" + }, + { + id: 2, + storeName: "강남 김밥천국", + body: "간단하게 먹기 좋음", + score: 4, + imageCount: 0, + createdAt: "2025-01-16T09:30:00.000Z" + } + ], + pagination: { + cursor: 2 + } + } + } + } + } + } + + #swagger.responses[500] = { + description: '서버 내부 오류', + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/ErrorResponse" + }, + example: { + resultType: "FAIL", + error: { + errorCode: "unknown", + reason: "서버 내부 오류가 발생했습니다.", + data: null + }, + success: null + } + } + } + } + */ + + const userId = + typeof req.params.user_id === "string" + ? parseInt(req.params.user_id) + : NaN; const cursor = typeof req.query.cursor === "string" ? parseInt(req.query.cursor) : null; @@ -48,4 +393,4 @@ export const handleListUserReviews = async (req, res, next) => { } catch (error) { next(error); } -}; \ No newline at end of file +}; diff --git a/Chapter_mission/src/controllers/store.controller.js b/Chapter_mission/src/controllers/store.controller.js index 8783ffa..c0ca3bd 100644 --- a/Chapter_mission/src/controllers/store.controller.js +++ b/Chapter_mission/src/controllers/store.controller.js @@ -3,13 +3,166 @@ import { StatusCodes } from "http-status-codes"; import { bodyToStore } from "../dtos/store.dto.js"; import { createStore } from "../services/store.service.js"; +// 가게 등록 export const handleAddStore = async (req, res, next) => { - const { regionId } = req.params; + /* + #swagger.tags = ['Stores'] + #swagger.summary = '가게 등록' + #swagger.description = '특정 지역(region_id)에 새로운 가게를 등록합니다.' - console.log("가게 등록 요청:", req.body); + #swagger.parameters['region_id'] = { + in: 'path', + required: true, + schema: { + type: 'integer' + }, + description: '가게가 속한 지역의 ID' + } + + #swagger.requestBody = { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + categoryId: { type: "integer" }, + name: { type: "string" }, + address: { type: "string" }, + description: { type: "string" } + }, + required: ["categoryId", "name"], + example: { + categoryId: 4, + name: "홍대 떡볶이", + address: "서울 마포구 홍익로 10", + description: "매운맛이 매력적인 분식집" + } + } + } + } + } + + #swagger.responses[201] = { + description: '가게 등록 성공', + content: { + "application/json": { + schema: { + $ref: '#/components/schemas/SuccessResponse' + }, + example: { + resultType: "SUCCESS", + error: null, + success: { + id: 12, + regionId: 3, + categoryId: 4, + name: "홍대 떡볶이", + address: "서울 마포구 홍익로 10", + description: "매운맛이 매력적인 분식집", + createdAt: "2025-01-15T12:00:00.000Z", + updatedAt: "2025-01-15T12:00:00.000Z" + } + } + } + } + } + + #swagger.responses[400] = { + description: '잘못된 요청 (필드 누락 또는 형식 오류)', + content: { + "application/json": { + schema: { + $ref: '#/components/schemas/ErrorResponse' + }, + example: { + resultType: "FAIL", + error: { + errorCode: "unknown", + reason: "요청 형식이 올바르지 않습니다. (예: name 누락, categoryId 타입 오류 등)", + data: null + }, + success: null + } + } + } + } + + #swagger.responses[404] = { + description: '해당 region_id가 존재하지 않는 경우', + content: { + "application/json": { + schema: { + $ref: '#/components/schemas/ErrorResponse' + }, + example: { + resultType: "FAIL", + error: { + errorCode: "U007", + reason: "해당 지역(region_id)이 존재하지 않습니다.", + data: { regionId: 3 } + }, + success: null + } + } + } + } + + #swagger.responses[409] = { + description: '이미 동일한 이름의 가게가 존재하는 경우 등', + content: { + "application/json": { + schema: { + $ref: '#/components/schemas/ErrorResponse' + }, + example: { + resultType: "FAIL", + error: { + errorCode: "U008", + reason: "이미 동일한 이름의 가게가 존재합니다.", + data: { name: "홍대 떡볶이" } + }, + success: null + } + } + } + } + + #swagger.responses[500] = { + description: '서버 내부 오류', + content: { + "application/json": { + schema: { + $ref: '#/components/schemas/ErrorResponse' + }, + example: { + resultType: "FAIL", + error: { + errorCode: "unknown", + reason: "서버 내부 오류가 발생했습니다.", + data: null + }, + success: null + } + } + } + } + */ + + // path param에서 region_id 가져와서 숫자로 변환 + const regionIdParam = req.params.region_id; + const regionIdNum = Number(regionIdParam); + + console.log("가게 등록 요청:", { + regionIdParam, + body: req.body, + }); try { - const storeData = bodyToStore(req.body, regionId); + // DTO에 number 타입 regionId 넘김 + const storeData = bodyToStore(req.body, regionIdNum); + + // 서비스 레이어 호출 const store = await createStore(storeData); res.status(StatusCodes.CREATED).success(store); diff --git a/Chapter_mission/src/controllers/user.controller.js b/Chapter_mission/src/controllers/user.controller.js index 2c5dbe0..d58a845 100644 --- a/Chapter_mission/src/controllers/user.controller.js +++ b/Chapter_mission/src/controllers/user.controller.js @@ -1,9 +1,119 @@ +// src/controllers/user.controller.js import { StatusCodes } from "http-status-codes"; import { bodyToUser } from "../dtos/user.dto.js"; import { userSignUp } from "../services/user.service.js"; import bcrypt from "bcrypt"; export const handleUserSignUp = async (req, res, next) => { + /* + #swagger.tags = ['Users'] + #swagger.summary = '회원가입' + #swagger.description = '이메일, 이름, 비밀번호, 닉네임으로 회원을 등록합니다.' + + #swagger.requestBody = { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + email: { type: "string", format: "email", description: "로그인에 사용할 이메일" }, + name: { type: "string", description: "실명" }, + password: { type: "string", description: "로그인 비밀번호" }, + nickname: { type: "string", description: "닉네임" }, + phoneNumber: { type: "string", description: "전화번호(선택)" }, + gender: { + type: "string", + description: "성별(선택)", + enum: ["MALE", "FEMALE", "OTHER", "UNKNOWN"] + }, + birth: { + type: "string", + format: "date", + description: "생년월일 (YYYY-MM-DD, 선택)" + }, + profileImage: { + type: "string", + description: "프로필 이미지 URL (선택)" + } + }, + required: ["email", "name", "password", "nickname"], + example: { + email: "test@example.com", + name: "김예원", + password: "qwer1234!", + nickname: "워니", + phoneNumber: "010-1234-5678", + gender: "FEMALE", + birth: "2000-01-01", + profileImage: "https://example.com/profile.png" + } + } + } + } + } + + #swagger.responses[201] = { + description: '회원가입 성공', + content: { + "application/json": { + schema: { + $ref: '#/components/schemas/SuccessResponse' + }, + example: { + resultType: "SUCCESS", + error: null, + success: { + id: 1, + email: "test@example.com", + nickname: "워니" + } + } + } + } + } + + #swagger.responses[400] = { + description: '비밀번호 규칙 위반 등 잘못된 요청', + content: { + "application/json": { + schema: { + $ref: '#/components/schemas/ErrorResponse' + }, + example: { + resultType: "FAIL", + error: { + errorCode: "U002", + reason: "비밀번호 규칙 위반", + data: null + }, + success: null + } + } + } + } + + #swagger.responses[409] = { + description: '이메일 중복', + content: { + "application/json": { + schema: { + $ref: '#/components/schemas/ErrorResponse' + }, + example: { + resultType: "FAIL", + error: { + errorCode: "U001", + reason: "이미 사용 중인 이메일입니다.", + data: { email: "test@example.com" } + }, + success: null + } + } + } + } + */ + console.log("회원가입 요청이 들어왔습니다!"); console.log("body:", req.body); @@ -23,3 +133,245 @@ export const handleUserSignUp = async (req, res, next) => { next(error); // 실패 응답 전역 에러 핸들러 } }; + + +// 내 정보 조회 + 구글 가입 유저에게 가이드 메시지 +export const handleGetMe = async (req, res, next) => { + /* + #swagger.tags = ['Users'] + #swagger.summary = '내 정보 조회' + #swagger.description = '로그인한 사용자의 정보를 조회합니다. + Google 로그인으로 가입했고 프로필 정보가 비어 있는 경우, + 닉네임/전화번호/생년월일을 채우라는 메시지를 안내합니다.' + + #swagger.responses[200] = { + description: '내 정보 조회 성공', + content: { + "application/json": { + schema: { + $ref: '#/components/schemas/SuccessResponse' + }, + example: { + resultType: "SUCCESS", + error: null, + success: { + user: { + id: "1", + email: "test@example.com", + name: "김예원", + nickname: "google_2512345678", + phoneNumber: null, + birth: null + }, + isProfileIncomplete: true, + guideMessage: "Google 로그인으로 가입되었습니다. 닉네임, 전화번호, 생년월일을 마이페이지에서 입력해 주세요." + } + } + } + } + } + */ + + try { + const userId = BigInt(req.user.id); // req.user.id는 문자열이므로 BigInt로 변환 + + const user = await prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user) { + return res.status(StatusCodes.NOT_FOUND).json({ + resultType: "FAIL", + error: { + errorCode: "U000", + reason: "사용자를 찾을 수 없습니다.", + data: null, + }, + success: null, + }); + } + + // Google 로그인 회원가입 판별 기준 + // googleVerify에서 password를 "GOOGLE_OAUTH_USER"로 넣어 기준으로 체크 가능 + const isGoogleUser = user.password === "GOOGLE_OAUTH_USER"; + + // 프로필이 미완성인지 판별 + const isNicknameTemp = typeof user.nickname === "string" && user.nickname.startsWith("google_"); + const isPhoneEmpty = !user.phoneNumber; + const isBirthEmpty = !user.birth; + + const isProfileIncomplete = + isGoogleUser && (isNicknameTemp || isPhoneEmpty || isBirthEmpty); + + const guideMessage = isProfileIncomplete + ? "Google 로그인으로 가입되었습니다. 닉네임, 전화번호, 생년월일을 마이페이지에서 입력해 주세요." + : null; + + return res.status(StatusCodes.OK).success({ + user: responseFromUser(user), + isProfileIncomplete, + guideMessage, + }); + } catch (error) { + next(error); + } +}; + +// 내 정보 수정 +export const handleUpdateMe = async (req, res, next) => { + /* + #swagger.tags = ['Users'] + #swagger.summary = '내 정보 수정' + #swagger.description = '로그인된 사용자의 프로필 정보를 수정합니다. (이름, 닉네임, 전화번호, 생일 등)' + + #swagger.requestBody = { + required: true, + content: { + "application/json": { + schema: { + type: "object", + description: "사용자 프로필에서 수정할 필드만 전송하면 됩니다.", + properties: { + name: { type: "string", description: "실명" }, + nickname: { type: "string", description: "닉네임" }, + phoneNumber: { type: "string", description: "전화번호" }, + birth: { + type: "string", + format: "date", + description: "생년월일 (YYYY-MM-DD)" + } + } + }, + example: { + name: "김예원", + nickname: "워니", + phoneNumber: "010-1234-5678", + birth: "2000-01-01" + } + } + } + } + + #swagger.responses[200] = { + description: '내 정보 수정 성공', + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/SuccessResponse" + }, + example: { + resultType: "SUCCESS", + error: null, + success: { + id: 1, + email: "test@example.com", + name: "김예원", + nickname: "워니", + phoneNumber: "010-1234-5678", + birth: "2000-01-01" + } + } + } + } + } + + #swagger.responses[400] = { + description: '잘못된 요청 (수정할 필드 없음, birth 형식 오류 등)', + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/ErrorResponse" + }, + example: { + resultType: "FAIL", + error: { + errorCode: "unknown", + reason: "수정할 정보가 없습니다. 최소 한 개 이상의 필드를 보내야 합니다.", + data: null + }, + success: null + } + } + } + } + + #swagger.responses[401] = { + description: '인증되지 않은 사용자 (로그인 필요)', + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/ErrorResponse" + }, + example: { + resultType: "FAIL", + error: { + errorCode: "AUTH001", + reason: "인증이 필요한 요청입니다.", + data: null + }, + success: null + } + } + } + } + + #swagger.responses[500] = { + description: '서버 내부 오류', + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/ErrorResponse" + }, + example: { + resultType: "FAIL", + error: { + errorCode: "unknown", + reason: "서버 내부 오류가 발생했습니다.", + data: null + }, + success: null + } + } + } + } + */ + + try { + // authMiddleware에서 세팅 req.user = { id, email, ... } + if (!req.user || !req.user.id) { + return res.status(StatusCodes.UNAUTHORIZED).json({ + resultType: "FAIL", + error: { + errorCode: "AUTH001", + reason: "인증이 필요한 요청입니다.", + data: null, + }, + success: null, + }); + } + + const userId = req.user.id; + + const updateData = bodyToUserUpdate(req.body); + + if (Object.keys(updateData).length === 0) { + return res.status(StatusCodes.BAD_REQUEST).json({ + resultType: "FAIL", + error: { + errorCode: "unknown", + reason: "수정할 정보가 없습니다. 최소 한 개 이상의 필드를 보내야 합니다.", + data: null, + }, + success: null, + }); + } + + const updatedUser = await updateUserProfile(userId, updateData); + + const response = responseFromUser(updatedUser); + + res.status(StatusCodes.OK).success(response); + } catch (error) { + next(error); + } +}; \ No newline at end of file diff --git a/Chapter_mission/src/controllers/userMission.controller.js b/Chapter_mission/src/controllers/userMission.controller.js index fa536f8..dbe2a14 100644 --- a/Chapter_mission/src/controllers/userMission.controller.js +++ b/Chapter_mission/src/controllers/userMission.controller.js @@ -1,18 +1,38 @@ // src/controllers/userMission.controller.js import { StatusCodes } from "http-status-codes"; -import { challengeMission } from "../services/userMission.service.js"; -import { listActiveMissions } from "../services/userMission.service.js"; -import { completeUserMission } from "../services/userMission.service.js"; +import { bodyToUserMission } from "../dtos/userMission.dto.js"; +import { + challengeMission, + listActiveMissions, + completeUserMission, +} from "../services/userMission.service.js"; // 미션 도전 export const handleChallengeMission = async (req, res, next) => { - console.log("미션 도전 요청:", req.params.missionId, req.body); + /* + #swagger.tags = ['UserMissions'] + #swagger.summary = '미션 도전' + #swagger.description = '로그인한 사용자가 특정 미션(mission_id)에 도전합니다.' - const missionId = Number(req.params.missionId); - const userId = Number(req.body.userId); + #swagger.parameters['mission_id'] = { + in: 'path', + required: true, + schema: { type: 'integer' }, + description: '도전할 미션 ID' + } + + // user_id를 받지 않기 때문에 body 없음 + */ try { - const challenge = await challengeMission(userId, missionId); + const missionId = Number(req.params.mission_id); + const userId = req.user.id; // JWT에서 온 아이디 + + // DTO에서 검증 + 데이터 정제 + const dto = bodyToUserMission(Number(userId), missionId); + + const challenge = await challengeMission(dto); + res.status(StatusCodes.CREATED).success(challenge); } catch (error) { next(error); @@ -21,9 +41,71 @@ export const handleChallengeMission = async (req, res, next) => { // 내가 진행 중인 미션 목록 조회 export const handleListActiveMissions = async (req, res, next) => { - const userId = Number(req.params.user_id); + /* + #swagger.tags = ['UserMissions'] + #swagger.summary = '내가 진행 중인 미션 목록 조회' + #swagger.description = '로그인한 사용자가 현재 진행 중인 미션 목록을 조회합니다.' + + #swagger.security = [{ bearerAuth: [] }] + + #swagger.responses[200] = { + description: '진행 중인 미션 목록 조회 성공', + content: { + "application/json": { + schema: { + $ref: '#/components/schemas/SuccessResponse' + }, + example: { + resultType: "SUCCESS", + error: null, + success: [ + { + id: 10, + missionId: 3, + missionTitle: "인증샷과 함께 리뷰 남기기", + storeName: "홍대 떡볶이", + storeAddress: "서울 마포구 홍익로 10", + status: "IN_PROGRESS", + startedAt: "2025-01-15T12:00:00.000Z" + }, + { + id: 11, + missionId: 4, + missionTitle: "친구와 함께 방문하기", + storeName: "강남 김밥천국", + storeAddress: "서울 강남구 역삼동 123-45", + status: "IN_PROGRESS", + startedAt: "2025-01-16T09:30:00.000Z" + } + ] + } + } + } + } + + #swagger.responses[500] = { + description: '서버 내부 오류', + content: { + "application/json": { + schema: { + $ref: '#/components/schemas/ErrorResponse' + }, + example: { + resultType: "FAIL", + error: { + errorCode: "unknown", + reason: "서버 내부 오류가 발생했습니다.", + data: null + }, + success: null + } + } + } + } + */ try { + const userId = Number(req.user.id); const result = await listActiveMissions(userId); res.status(StatusCodes.OK).success(result); @@ -34,12 +116,129 @@ export const handleListActiveMissions = async (req, res, next) => { // 미션 완료 export const handleCompleteMission = async (req, res, next) => { - const userMissionId = parseInt(req.params.userMissionId); + /* + #swagger.tags = ['UserMissions'] + #swagger.summary = '미션 완료 처리' + #swagger.description = '사용자가 도전 중이던 미션(user_mission_id)을 완료 상태로 변경합니다.' - console.log("미션 완료 요청:", userMissionId); + #swagger.parameters['user_mission_id'] = { + in: 'path', + required: true, + schema: { + type: 'integer' + }, + description: '사용자 미션(user_missions)의 ID' + } + + #swagger.responses[200] = { + description: '미션 완료 처리 성공', + content: { + "application/json": { + schema: { + $ref: '#/components/schemas/SuccessResponse' + }, + example: { + resultType: "SUCCESS", + error: null, + success: { + id: 10, + userId: 1, + missionId: 3, + status: "COMPLETED", + createdAt: "2025-01-15T12:00:00.000Z", + updatedAt: "2025-01-16T10:00:00.000Z" + } + } + } + } + } + + #swagger.responses[403] = { + description: '다른 사용자의 미션을 완료 처리하려는 경우', + content: { + "application/json": { + schema: { $ref: '#/components/schemas/ErrorResponse' }, + example: { + resultType: "FAIL", + error: { + errorCode: "AUTH003", + reason: "본인의 미션만 완료할 수 있습니다.", + data: { userMissionId: 10 } + }, + success: null + } + } + } + } + + #swagger.responses[404] = { + description: '해당 user_mission_id에 대한 도전 정보가 없는 경우', + content: { + "application/json": { + schema: { + $ref: '#/components/schemas/ErrorResponse' + }, + example: { + resultType: "FAIL", + error: { + errorCode: "U004", + reason: "해당 미션 도전 정보를 찾을 수 없습니다.", + data: { userMissionId: 10 } + }, + success: null + } + } + } + } + + #swagger.responses[409] = { + description: '이미 완료된 미션을 다시 완료 처리하려는 경우', + content: { + "application/json": { + schema: { + $ref: '#/components/schemas/ErrorResponse' + }, + example: { + resultType: "FAIL", + error: { + errorCode: "U005", + reason: "이미 완료된 미션입니다.", + data: { userMissionId: 10 } + }, + success: null + } + } + } + } + + #swagger.responses[500] = { + description: '서버 내부 오류', + content: { + "application/json": { + schema: { + $ref: '#/components/schemas/ErrorResponse' + }, + example: { + resultType: "FAIL", + error: { + errorCode: "unknown", + reason: "서버 내부 오류가 발생했습니다.", + data: null + }, + success: null + } + } + } + } + */ try { - const result = await completeUserMission(userMissionId); + const userId = req.user.id; + const userMissionId = Number(req.params.user_mission_id); + + console.log("미션 완료 요청:", { userId, userMissionId }); + + const result = await completeUserMission(userId, userMissionId); res.status(StatusCodes.OK).success(result); } catch (error) { next(error); diff --git a/Chapter_mission/src/db.config.js b/Chapter_mission/src/db.config.js index bb87550..ac5668e 100644 --- a/Chapter_mission/src/db.config.js +++ b/Chapter_mission/src/db.config.js @@ -1,6 +1,6 @@ import mysql from "mysql2/promise"; import dotenv from "dotenv"; -import PrismaClient from "@prisma/client"; +import { PrismaClient } from "@prisma/client"; dotenv.config(); diff --git a/Chapter_mission/src/dtos/mission.dto.js b/Chapter_mission/src/dtos/mission.dto.js index 7d7408d..d5674ce 100644 --- a/Chapter_mission/src/dtos/mission.dto.js +++ b/Chapter_mission/src/dtos/mission.dto.js @@ -1,9 +1,60 @@ +// src/dtos/mission.dto.js + +import { createBadRequestError } from "../error.js"; + export const bodyToMission = (body, storeId) => { + // storeId 검증 + if ( + typeof storeId !== "number" || + !Number.isInteger(storeId) || + storeId <= 0 + ) { + throw createBadRequestError("유효한 storeId가 필요합니다.", { storeId }); + } + + // title: 필수, 문자열 공백 제거 후 최소 한 글자 + if ( + typeof body.title !== "string" || + body.title.trim().length === 0 + ) { + throw createBadRequestError("title은 비어있지 않은 문자열이어야 합니다."); + } + + // description: 선택, 문자열 + if ( + body.description !== undefined && + typeof body.description !== "string" + ) { + throw createBadRequestError("description은 문자열이어야 합니다."); + } + + // point: 필수, number 음수 불가 + if ( + body.point === undefined || + typeof body.point !== "number" || + !Number.isInteger(body.point) || + body.point < 0 + ) { + throw createBadRequestError("point는 0 이상의 정수여야 합니다.", { + point: body.point, + }); + } + + // deadline: 선택, 날짜 형식 검사 + if (body.deadline !== undefined) { + const date = new Date(body.deadline); + if (isNaN(date.getTime())) { + throw createBadRequestError("deadline은 유효한 날짜 형식이어야 합니다.", { + deadline: body.deadline, + }); + } + } + return { storeId, - title: body.title, - description: body.description || null, - point: body.point || 0, + title: body.title.trim(), + description: body.description?.trim() || null, + point: body.point, deadline: body.deadline ? new Date(body.deadline) : null, }; }; diff --git a/Chapter_mission/src/dtos/review.dto.js b/Chapter_mission/src/dtos/review.dto.js index 14a4e89..406784d 100644 --- a/Chapter_mission/src/dtos/review.dto.js +++ b/Chapter_mission/src/dtos/review.dto.js @@ -1,15 +1,78 @@ // src/dtos/review.dto.js +import { createBadRequestError } from "../error.js"; +/** + * 리뷰 생성용 DTO 변환 + 검증 + * @param {object} body - 요청 바디 + * @param {number} storeId - path param에서 온 store_id + */ export const bodyToReview = (body, storeId) => { + // storeId 검증 + if (!Number.isInteger(storeId) || storeId <= 0) { + throw createBadRequestError("유효하지 않은 store_id 입니다.", { + storeId, + }); + } + + const { userMissionId, score, body: content, imageCount } = body; + + // userMissionId: 필수, 양의 정수 + if ( + userMissionId === undefined || + userMissionId === null || + !Number.isInteger(userMissionId) || + userMissionId <= 0 + ) { + throw createBadRequestError( + "userMissionId는 1 이상의 정수여야 합니다.", + { userMissionId } + ); + } + + // body: 필수, 비어있지 않은 문자열 + if (typeof content !== "string" || content.trim().length === 0) { + throw createBadRequestError("body는 비어있지 않은 문자열이어야 합니다.", { + body: content, + }); + } + + // score: 필수, 1~5 사이 정수 + if ( + score === undefined || + score === null || + !Number.isInteger(score) || + score < 1 || + score > 5 + ) { + throw createBadRequestError( + "score는 1 이상 5 이하의 정수여야 합니다.", + { score } + ); + } + + // imageCount: 선택, 있으면 0 이상 정수 + let parsedImageCount = 0; + if (imageCount !== undefined && imageCount !== null) { + if (!Number.isInteger(imageCount) || imageCount < 0) { + throw createBadRequestError( + "imageCount는 0 이상의 정수여야 합니다.", + { imageCount } + ); + } + parsedImageCount = imageCount; + } + + // 실제 DB에 넣을 형태로 변환 return { - storeId, // URL 경로 파라미터 - userMissionId: body.userMissionId, // 미션 ID (필수) - body: body.body, // 리뷰 내용 - score: body.score, // 평점 (1~5) - imageCount: body.imageCount || 0, // 이미지 개수 (기본 0) + storeId, + userMissionId, + body: content.trim(), + score, + imageCount: parsedImageCount, }; }; + // 단일 리뷰 생성/조회 export const responseFromReview = (review) => { if (!review) return null; diff --git a/Chapter_mission/src/dtos/store.dto.js b/Chapter_mission/src/dtos/store.dto.js index 7f064b2..8325059 100644 --- a/Chapter_mission/src/dtos/store.dto.js +++ b/Chapter_mission/src/dtos/store.dto.js @@ -1,12 +1,58 @@ // src/dtos/store.dto.js +import { createBadRequestError } from "../error.js"; + export const bodyToStore = (body, regionId) => { + if ( + typeof regionId !== "number" || + !Number.isInteger(regionId) || + regionId <= 0 + ) { + throw createBadRequestError("유효한 regionId가 필요합니다.", { regionId }); + } + + // categoryId: 필수, 양의 정수 + if ( + body.categoryId === undefined || + typeof body.categoryId !== "number" || + !Number.isInteger(body.categoryId) || + body.categoryId <= 0 + ) { + throw createBadRequestError("categoryId는 1 이상의 정수여야 합니다.", { + categoryId: body.categoryId, + }); + } + + // name: 필수, 문자열 공백 제거 후 최소 한 글자 + if ( + typeof body.name !== "string" || + body.name.trim().length === 0 + ) { + throw createBadRequestError("name은 비어있지 않은 문자열이어야 합니다."); + } + + // address: 선택값, 문자열인지 확인 + if ( + body.address !== undefined && + typeof body.address !== "string" + ) { + throw createBadRequestError("address는 문자열이어야 합니다."); + } + + // description: 선택값, 문자열인지 확인 + if ( + body.description !== undefined && + typeof body.description !== "string" + ) { + throw createBadRequestError("description은 문자열이어야 합니다."); + } + return { regionId, categoryId: body.categoryId, - name: body.name, - address: body.address || null, - description: body.description || null, + name: body.name.trim(), + address: body.address?.trim() || null, + description: body.description?.trim() || null, }; }; diff --git a/Chapter_mission/src/dtos/user.dto.js b/Chapter_mission/src/dtos/user.dto.js index 9e5d465..dbc642f 100644 --- a/Chapter_mission/src/dtos/user.dto.js +++ b/Chapter_mission/src/dtos/user.dto.js @@ -1,20 +1,67 @@ -// 요청 Body -> DB에 넣을 User 데이터 형태로 변환 +// 회원가입 요청 Body -> DB 저장용 데이터 변환 + 검증 export const bodyToUser = (body) => { - const birth = body.birth ? new Date(body.birth) : null; + // 필수값 검증: email, name, password, nickname + if (!body.email || typeof body.email !== "string") { + throw new Error("email은 필수이며 문자열이어야 합니다."); + } + if (!body.password || typeof body.password !== "string") { + throw new Error("password는 필수이며 문자열이어야 합니다."); + } + + if (!body.name || typeof body.name !== "string" || body.name.trim().length === 0) { + throw new Error("name은 필수이며 비어있지 않은 문자열이어야 합니다."); + } + + if (!body.nickname || typeof body.nickname !== "string" || body.nickname.trim().length === 0) { + throw new Error("nickname은 필수이며 비어있지 않은 문자열이어야 합니다."); + } + + // 선택값 검증 + + // phoneNumber: 선택, 있으면 문자열 + 공백만 안됨 + if (body.phoneNumber !== undefined && body.phoneNumber !== null) { + if (typeof body.phoneNumber !== "string" || body.phoneNumber.trim().length === 0) { + throw new Error("phoneNumber는 비어있지 않은 문자열이어야 합니다."); + } + } + + // birth: 선택, 유효한 날짜인지 확인 + if (body.birth !== undefined && body.birth !== null) { + const birthDate = new Date(body.birth); + if (Number.isNaN(birthDate.getTime())) { + throw new Error("birth는 YYYY-MM-DD 형식의 유효한 날짜여야 합니다."); + } + } + + // gender: 선택, enum 값 체크 (Prisma와 통일) + if (body.gender !== undefined && !["MALE", "FEMALE", "OTHER", "UNKNOWN"].includes(body.gender)) { + throw new Error("gender는 MALE, FEMALE, OTHER, UNKNOWN 중 하나여야 합니다."); + } + + // profileImage: 선택, 있으면 문자열 + if (body.profileImage !== undefined && typeof body.profileImage !== "string") { + throw new Error("profileImage는 문자열(URL)이어야 합니다."); + } + + // DB에 넣을 형태로 변환 return { - email: body.email, // 필수 - password: body.password, // 필수 (암호화된 상태로 service에서 넘김) - name: body.name || null, // 선택 - nickname: body.nickname, // 필수 - gender: body.gender || "UNKNOWN", // 선택 (기본값) - birth, // 선택 - phoneNumber: body.phoneNumber, // 필수 - profileImage: body.profileImage || null, // 선택 (URL) + email: body.email, + password: body.password, // 해시는 컨트롤러에서 처리 + name: body.name.trim(), + nickname: body.nickname.trim(), + gender: body.gender || "UNKNOWN", + birth: body.birth ? new Date(body.birth) : null, + phoneNumber: + body.phoneNumber !== undefined && body.phoneNumber !== null + ? body.phoneNumber.trim() + : null, + profileImage: body.profileImage || null, }; }; -// DB에서 조회된 User -> Client 응답 구조로 변환 + +// DB User -> 응답용 구조 변환 export const responseFromUser = (user) => { if (!user) return null; @@ -25,10 +72,72 @@ export const responseFromUser = (user) => { nickname: user.nickname, gender: user.gender, birth: user.birth, - phoneNumber: user.phoneNumber, // snake -> camel 수정 - profileImage: user.profileImage, // snake -> camel 수정 - createdAt: user.createdAt, // snake -> camel 수정 - updatedAt: user.updatedAt, // snake -> camel 수정 + phoneNumber: user.phoneNumber, + profileImage: user.profileImage, + createdAt: user.createdAt, + updatedAt: user.updatedAt, }; }; + +// 사용자 정보 부분 수정용 DTO: PATCH /users/me +export const bodyToUserUpdate = (body) => { + const updateData = {}; + + // name (선택, 비어 있지 않은 문자열) + if (body.name !== undefined) { + if (typeof body.name !== "string" || body.name.trim().length === 0) { + throw new Error("name은 비어있지 않은 문자열이어야 합니다."); + } + updateData.name = body.name.trim(); + } + + // nickname (선택, 비어 있지 않은 문자열) + if (body.nickname !== undefined) { + if (typeof body.nickname !== "string" || body.nickname.trim().length === 0) { + throw new Error("nickname은 비어있지 않은 문자열이어야 합니다."); + } + updateData.nickname = body.nickname.trim(); + } + + // phoneNumber (선택, 비어 있지 않은 문자열) + if (body.phoneNumber !== undefined) { + if (typeof body.phoneNumber !== "string" || body.phoneNumber.trim().length === 0) { + throw new Error("phoneNumber는 비어있지 않은 문자열이어야 합니다."); + } + updateData.phoneNumber = body.phoneNumber.trim(); + } + + // birth (선택, 날짜 형식 검증) + if (body.birth !== undefined && body.birth !== null) { + const birthDate = new Date(body.birth); + if (Number.isNaN(birthDate.getTime())) { + throw new Error("birth는 YYYY-MM-DD 형식의 유효한 날짜여야 합니다."); + } + + updateData.birth = body.birth; + } + + // gender (선택, 지정된 값만 허용) + if (body.gender !== undefined) { + if (!["MALE", "FEMALE", "UNKNOWN"].includes(body.gender)) { + throw new Error("gender는 MALE, FEMALE, UNKNOWN 중 하나여야 합니다."); + } + updateData.gender = body.gender; + } + + // profileImage (선택, 문자열이어야 함) + if (body.profileImage !== undefined) { + if (typeof body.profileImage !== "string") { + throw new Error("profileImage는 문자열(URL)이어야 합니다."); + } + updateData.profileImage = body.profileImage; + } + + // 실제로 수정할 필드가 하나도 없으면 에러 + if (Object.keys(updateData).length === 0) { + throw new Error("수정할 정보가 없습니다. 최소 한 개 이상의 필드를 보내야 합니다."); + } + + return updateData; +}; \ No newline at end of file diff --git a/Chapter_mission/src/dtos/userMission.dto.js b/Chapter_mission/src/dtos/userMission.dto.js index 0aa8779..d642057 100644 --- a/Chapter_mission/src/dtos/userMission.dto.js +++ b/Chapter_mission/src/dtos/userMission.dto.js @@ -1,9 +1,19 @@ // src/dtos/userMission.dto.js -export const bodyToUserMission = (body, missionId) => { +import { createBadRequestError } from "../error.js"; + +export const bodyToUserMission = (userId, missionId) => { + if (!missionId || typeof missionId !== "number" || missionId <= 0) { + throw createBadRequestError("유효한 missionId가 필요합니다.", { missionId }); + } + + if (!userId || typeof userId !== "number" || userId <= 0) { + throw createBadRequestError("유효한 userId가 필요합니다.", { userId }); + } + return { - userId: body.user_id, - missionId: missionId, + userId, + missionId, }; }; diff --git a/Chapter_mission/src/error.js b/Chapter_mission/src/error.js index 41cf86d..a02770d 100644 --- a/Chapter_mission/src/error.js +++ b/Chapter_mission/src/error.js @@ -1,6 +1,7 @@ // 이메일 중복 export class DuplicateUserEmailError extends Error { errorCode = "U001"; + statusCode = 409; constructor(reason, data) { super(reason); @@ -12,6 +13,7 @@ export class DuplicateUserEmailError extends Error { // 비밀번호 규칙 위반 export class PasswordRuleError extends Error { errorCode = "U002"; + statusCode = 400; constructor(reason, data) { super(reason); @@ -23,6 +25,7 @@ export class PasswordRuleError extends Error { // 이미 도전 중인 미션 export class MissionAlreadyInProgressError extends Error { errorCode = "U003"; + statusCode = 409; constructor(reason, data) { super(reason); @@ -34,6 +37,7 @@ export class MissionAlreadyInProgressError extends Error { // 미션 정보 찾을 수 없음 export class MissionNotFoundError extends Error { errorCode = "U004"; + statusCode = 404; constructor(reason, data) { super(reason); @@ -45,6 +49,7 @@ export class MissionNotFoundError extends Error { // 이미 완료된 미션 export class MissionAlreadyCompletedError extends Error { errorCode = "U005"; + statusCode = 409; constructor(reason, data) { super(reason); @@ -56,6 +61,7 @@ export class MissionAlreadyCompletedError extends Error { // 존재하지 않는 가게 export class StoreNotFoundError extends Error { errorCode = "U006"; + statusCode = 404; constructor(reason, data) { super(reason); @@ -67,6 +73,7 @@ export class StoreNotFoundError extends Error { // 존재하지 않는 지역 export class RegionNotFoundError extends Error { errorCode = "U007"; + statusCode = 404; constructor(reason, data) { super(reason); @@ -78,10 +85,20 @@ export class RegionNotFoundError extends Error { // 가게 중복 export class DuplicateStoreError extends Error { errorCode = "U008"; + statusCode = 409; constructor(reason, data) { super(reason); this.reason = reason; this.data = data; } +} + +export class ForbiddenError extends Error { + constructor(reason = "접근 권한이 없습니다.", data = null) { + super(reason); + this.statusCode = 403; + this.errorCode = "AUTH003"; + this.data = data; + } } \ No newline at end of file diff --git a/Chapter_mission/src/index.js b/Chapter_mission/src/index.js new file mode 100644 index 0000000..8f701cc --- /dev/null +++ b/Chapter_mission/src/index.js @@ -0,0 +1,250 @@ +import dotenv from "dotenv"; +import express from "express"; +import cors from "cors"; +import swaggerAutogen from "swagger-autogen"; +import swaggerUiExpress from "swagger-ui-express"; +import morgan from "morgan"; +import cookieParser from "cookie-parser"; +import passport from "passport"; +import { googleStrategy, jwtStrategy } from "./auth.config.js"; +import authRouter from "./routes/auth.routes.js"; +import usersRouter from "./routes/users.route.js"; +import storesRouter from "./routes/stores.route.js"; +import missionsRouter from "./routes/missions.route.js"; +import { prisma } from "./db.config.js"; + +dotenv.config(); + +passport.use(googleStrategy); +passport.use(jwtStrategy); + +const app = express(); +const port = process.env.PORT; + +/** + * 공통 응답을 사용할 수 있는 헬퍼 함수 등록 + */ +app.use((req, res, next) => { + res.success = (success) => { + return res.json({ resultType: "SUCCESS", error: null, success }); + }; + + next(); +}); + +/** +*전역 미들웨어 등록 +*/ +app.use(cors()); // cors 방식 허용 +app.use(express.static('public')); // 정적 파일 접근 +app.use(express.json()); // request의 본문을 json으로 해석할 수 있도록 함 (JSON 형태의 요청 body를 파싱하기 위함) +app.use(express.urlencoded({ extended: false })); // 단순 객체 문자열 형태로 본문 데이터 해석 +app.use(morgan('dev')); +app.use(cookieParser()); + +// passport 초기화 +app.use(passport.initialize()); + +app.get("/", (req, res) => { + res.send("Hello World!"); +}); + +/** + * Swagger 설정 + */ +app.use( + "/docs", + swaggerUiExpress.serve, + swaggerUiExpress.setup({}, { + swaggerOptions: { + url: "/openapi.json", + }, + }) +); + +app.get("/openapi.json", async (req, res, next) => { + // #swagger.ignore = true + try { + const options = { + openapi: "3.0.0", + disableLogs: true, + writeOutputFile: false, + }; + const outputFile = "/dev/null"; // 파일 출력 사용 안 함 + const routes = ["./index.js"]; // 이 파일 기준 경로 + const doc = { + info: { + title: "UMC 9th", + description: "UMC 9th Node.js 테스트 프로젝트입니다.", + }, + host: `localhost:${port}`, + schemes: ["http"], + components: { + schemas: { + ErrorInfo: { + type: "object", + properties: { + errorCode: { + type: "string", + example: "U001" + }, + reason: { + type: "string", + example: "이미 사용 중인 이메일입니다." + }, + data: { + type: "object", + nullable: true, + example: { email: "test@example.com" }, + }, + }, + required: ["errorCode"], + }, + ErrorResponse: { + type: "object", + properties: { + resultType: { + type: "string", + enum: ["FAIL"], + example: "FAIL", + }, + error: { + $ref: "#/components/schemas/ErrorInfo", + }, + success: { + nullable: true, + example: null, + }, + }, + required: ["resultType", "error"], + }, + // 성공 응답 기본 형태 + SuccessResponse: { + type: "object", + properties: { + resultType: { + type: "string", + enum: ["SUCCESS"], + example: "SUCCESS", + }, + error: { + nullable: true, + example: null, + }, + success: { + type: "object", + description: + "각 API에서 예시 override" + }, + }, + required: ["resultType", "success"], + }, + + // 구글 OAuth + OAuthTokenPair: { + type: "object", + properties: { + accessToken: { + type: "string", + description: "JWT Access Token", + example: + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access.payload.signature", + }, + refreshToken: { + type: "string", + description: "JWT Refresh Token", + example: + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh.payload.signature", + }, + }, + required: ["accessToken", "refreshToken"], + }, + + GoogleOAuthLoginResponse: { + type: "object", + properties: { + resultType: { + type: "string", + enum: ["SUCCESS"], + example: "SUCCESS", + }, + error: { + nullable: true, + example: null, + }, + success: { + type: "object", + properties: { + message: { + type: "string", + example: "Google 로그인 성공!", + }, + tokens: { + $ref: "#/components/schemas/OAuthTokenPair", + }, + }, + required: ["message", "tokens"], + }, + }, + required: ["resultType", "success"], + }, + }, + } + }; + + const result = await swaggerAutogen(options)(outputFile, routes, doc); + res.json(result ? result.data : null); + } catch (err) { + next(err); + } +}); + + +/** + * 라우터 설정 + */ +// OAuth / 인증 라우터 +app.use("/", authRouter); + +// 도메인 라우터 +app.use("/api/v1", usersRouter); +app.use("/api/v1", storesRouter); +app.use("/api/v1", missionsRouter); + + +// JWT 로그인 확인 미들웨어 +const isLogin = passport.authenticate('jwt', { session: false }); + +// 마이페이지 라우트 +app.get('/mypage', isLogin, (req, res) => { + res.status(200).success({ + message: `인증 성공! ${req.user.name}님의 마이페이지입니다.`, + user: req.user, + }); +}); + + +/** + * 전역 오류를 처리하기 위한 미들웨어 + */ +app.use((err, req, res, next) => { + if (res.headersSent) { + return next(err); + } + + const status = err.statusCode || 500; + + return res.status(status).json({ + resultType: "FAIL", + error: { + errorCode: err.errorCode || "unknown", + reason: err.reason || err.message || null, + data: err.data || null, + }, + success: null, + }); +}); + +app.listen(port, () => { + console.log(`Example app listening on port ${port}`); +}); \ No newline at end of file diff --git a/Chapter_mission/src/middlewares/auth.middleware.js b/Chapter_mission/src/middlewares/auth.middleware.js new file mode 100644 index 0000000..ab49f19 --- /dev/null +++ b/Chapter_mission/src/middlewares/auth.middleware.js @@ -0,0 +1,43 @@ +import jwt from "jsonwebtoken"; +import { StatusCodes } from "http-status-codes"; + +export const authMiddleware = (req, res, next) => { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return res.status(StatusCodes.UNAUTHORIZED).json({ + resultType: "FAIL", + error: { + errorCode: "AUTH001", + reason: "인증이 필요한 요청입니다. Authorization 헤더가 없습니다.", + data: null + }, + success: null + }); + } + + const token = authHeader.split(" ")[1]; + + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET); + + // decoded = { id: 1, email: "...", iat..., exp... } + + req.user = { + id: decoded.id, + email: decoded.email + }; + + next(); + } catch (err) { + return res.status(StatusCodes.UNAUTHORIZED).json({ + resultType: "FAIL", + error: { + errorCode: "AUTH002", + reason: "유효하지 않은 토큰입니다.", + data: null + }, + success: null + }); + } +}; \ No newline at end of file diff --git a/Chapter_mission/src/repositories/userMission.repository.js b/Chapter_mission/src/repositories/userMission.repository.js index 32a624a..6c73828 100644 --- a/Chapter_mission/src/repositories/userMission.repository.js +++ b/Chapter_mission/src/repositories/userMission.repository.js @@ -1,12 +1,46 @@ // src/repositories/userMission.repository.js import { prisma } from "../db.config.js"; +/** + * JS number / string / bigint 형태로 받은 id를 + * Prisma BigInt 컬럼에 안전하게 넣기 위해 BigInt로 변환하는 헬퍼 + */ +const toBigInt = (value, fieldName = "id") => { + if (value === undefined || value === null) { + throw new Error(`${fieldName}가 필요합니다.`); + } + + // 이미 BigInt면 그대로 사용 + if (typeof value === "bigint") { + return value; + } + + // number인 경우: 정수인지 확인 후 BigInt로 변환 + if (typeof value === "number") { + if (!Number.isInteger(value)) { + throw new Error(`${fieldName}는 정수여야 합니다.`); + } + return BigInt(value); + } + + // string인 경우: 공백 제거 후 숫자로 변환 가능해야 함 + if (typeof value === "string") { + const trimmed = value.trim(); + if (trimmed.length === 0 || Number.isNaN(Number(trimmed))) { + throw new Error(`${fieldName}는 숫자 형태의 문자열이어야 합니다.`); + } + return BigInt(trimmed); + } + + throw new Error(`${fieldName}는 정수 또는 정수 형태의 문자열이어야 합니다.`); +}; + // 이미 도전 중인지 확인 export const findActiveChallenge = async (userId, missionId) => { return await prisma.userMission.findFirst({ where: { - userId, - missionId, + userId: toBigInt(userId, "userId"), + missionId: toBigInt(missionId, "missionId"), status: "IN_PROGRESS", }, }); @@ -16,17 +50,17 @@ export const findActiveChallenge = async (userId, missionId) => { export const addUserMission = async (userId, missionId) => { return await prisma.userMission.create({ data: { - userId, - missionId, + userId: toBigInt(userId, "userId"), + missionId: toBigInt(missionId, "missionId"), status: "IN_PROGRESS", }, }); }; -// 생성된 도전 정보 조회(삭제) -export const getUserMissionById = async (id) => { +// user_mission 단건 조회 +export const getUserMissionById = async (userMissionId) => { return await prisma.userMission.findUnique({ - where: { id }, + where: { id: toBigInt(userMissionId, "userMissionId") }, }); }; @@ -34,7 +68,7 @@ export const getUserMissionById = async (id) => { export const findActiveMissionsByUserId = async (userId) => { return await prisma.userMission.findMany({ where: { - userId, + userId: toBigInt(userId, "userId"), status: "IN_PROGRESS", }, include: { @@ -52,9 +86,8 @@ export const findActiveMissionsByUserId = async (userId) => { // 미션 상태 업데이트 export const updateUserMissionStatus = async (userMissionId, newStatus) => { - const updated = await prisma.userMission.update({ - where: { id: userMissionId }, + return await prisma.userMission.update({ + where: { id: toBigInt(userMissionId, "userMissionId") }, data: { status: newStatus }, }); - return updated; }; \ No newline at end of file diff --git a/Chapter_mission/src/routes/auth.routes.js b/Chapter_mission/src/routes/auth.routes.js new file mode 100644 index 0000000..eeb452c --- /dev/null +++ b/Chapter_mission/src/routes/auth.routes.js @@ -0,0 +1,57 @@ +// routes/auth.routes.js +import passport from "passport"; +import express from "express"; +const router = express.Router(); + +// OAuth2 Google 로그인 엔드포인트 +router.get("/oauth2/login/google", + passport.authenticate("google", { session: false }) +); + +router.get("/oauth2/callback/google", + passport.authenticate("google", { + session: false, + failureRedirect: "/login-failed", + }), + (req, res) => { + /* + #swagger.tags = ['Auth'] + #swagger.summary = 'Google OAuth 콜백' + #swagger.description = 'Google 로그인 성공 후 Access Token, Refresh Token을 발급합니다.' + + #swagger.responses[200] = { + description: 'Google OAuth 로그인 성공', + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/GoogleOAuthLoginResponse" + } + } + } + } + + #swagger.responses[401] = { + description: 'Google OAuth 로그인 실패', + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/ErrorResponse" + } + } + } + } + */ + + const tokens = req.user; + + res.json({ + resultType: "SUCCESS", + success: { + message: "Google 로그인 성공!", + tokens, + } + }); + } +); + +export default router; \ No newline at end of file diff --git a/Chapter_mission/src/routes/missions.route.js b/Chapter_mission/src/routes/missions.route.js new file mode 100644 index 0000000..3495bf5 --- /dev/null +++ b/Chapter_mission/src/routes/missions.route.js @@ -0,0 +1,18 @@ +// src/routes/missions.route.js +import { Router } from "express"; +import { authMiddleware } from "../middlewares/auth.middleware"; +import { + handleChallengeMission, + handleCompleteMission, +} from "../controllers/userMission.controller.js"; + +const router = Router(); + +router.post("/missions/:mission_id/challenges", authMiddleware, handleChallengeMission); +router.patch( + "/user-missions/:user_mission_id/complete", + authMiddleware, + handleCompleteMission +); + +export default router; \ No newline at end of file diff --git a/Chapter_mission/src/routes/stores.route.js b/Chapter_mission/src/routes/stores.route.js new file mode 100644 index 0000000..3225d5d --- /dev/null +++ b/Chapter_mission/src/routes/stores.route.js @@ -0,0 +1,24 @@ +// src/routes/stores.route.js + +import { Router } from "express"; +import { authMiddleware } from "../middlewares/auth.middleware"; +import { handleAddStore } from "../controllers/store.controller.js"; +import { handleAddMission, + handleListMissionsByStore, } from "../controllers/mission.controller.js"; +import { handleAddReview, + handleListStoreReviews, } from "../controllers/review.controller.js"; + +const router = Router(); + +// 지역 + 가게 +router.post("/regions/:region_id/stores", authMiddleware, handleAddStore); + +// 가게 미션 +router.get("/stores/:store_id/missions", handleListMissionsByStore); +router.post("/stores/:store_id/missions", authMiddleware, handleAddMission); + +// 가게 리뷰 +router.post("/stores/:store_id/reviews", authMiddleware, handleAddReview); +router.get("/stores/:store_id/reviews", handleListStoreReviews); + +export default router; \ No newline at end of file diff --git a/Chapter_mission/src/routes/users.route.js b/Chapter_mission/src/routes/users.route.js new file mode 100644 index 0000000..7a9b5ef --- /dev/null +++ b/Chapter_mission/src/routes/users.route.js @@ -0,0 +1,21 @@ +// src/routes/users.route.js + +import { Router } from "express"; +import { authMiddleware } from "../middlewares/auth.middleware"; +import { handleUserSignUp, + handleUpdateMe, + handleGetMe, +} from "../controllers/user.controller.js"; +import { handleListUserReviews} from "../controllers/review.controller.js"; +import { handleListActiveMissions } from "../controllers/userMission.controller.js"; + + +const router = Router(); + +router.post("/users/signup", handleUserSignUp); +router.get("/users/me", authMiddleware, handleGetMe); +router.patch("/users/me", authMiddleware, handleUpdateMe); +router.get("/users/:user_id/reviews", handleListUserReviews); +router.get("/users/me/missions", authMiddleware, handleListActiveMissions); + +export default router; \ No newline at end of file diff --git a/Chapter_mission/src/services/review.service.js b/Chapter_mission/src/services/review.service.js index 24458da..2513abe 100644 --- a/Chapter_mission/src/services/review.service.js +++ b/Chapter_mission/src/services/review.service.js @@ -2,7 +2,7 @@ import { getStoreById, getUserMissionById, - findRiviewByUserMissionId, + findReviewByUserMissionId, createReview, getAllStoreReviews, getUserReviews, @@ -21,19 +21,20 @@ export const addReview = async (reviewData) => { // 가게 존재 확인 const store = await getStoreById(storeId); if (!store) { - // 커스텀 에러 처리 구현 + throw new StoreNotFoundError("존재하지 않는 가게입니다.", { storeId }); } // 유저 미션 존재 확인 const userMission = await getUserMissionById(userMissionId); if (!userMission) { - // 커스텀 에러 처리 구현 + throw new MissionNotFoundError("미션 정보를 찾을 수 없습니다.", { userMissionId }); } // 중복 리뷰 방지 - const existing = await findRiviewByUserMissionId(userMissionId); + const existing = await findReviewByUserMissionId(userMissionId); if (existing) { - // 커스텀 에러 처리 구현 + throw new MissionAlreadyCompletedError( + "이미 리뷰를 작성한 미션입니다.", { userMissionId }); } // 리뷰 등록(생성) diff --git a/Chapter_mission/src/services/user.service.js b/Chapter_mission/src/services/user.service.js index a4928a1..68fee02 100644 --- a/Chapter_mission/src/services/user.service.js +++ b/Chapter_mission/src/services/user.service.js @@ -3,7 +3,6 @@ import { prisma } from "../db.config.js"; import { DuplicateUserEmailError } from "../error.js"; import { responseFromUser } from "../dtos/user.dto.js"; -import { DuplicateUserEmailError } from "../error.js"; /** * 회원가입 서비스 (Prisma 리팩터링 버전) @@ -36,4 +35,16 @@ export const userSignUp = async (data) => { // DTO로 변환 후 반환 return responseFromUser(createdUser); +}; + + +export const updateUserProfile = async (userId, updateData) => { + const id = Number(userId); + + const user = await prisma.user.update({ + where: { id }, + data: updateData, + }); + + return user; }; \ No newline at end of file diff --git a/Chapter_mission/src/services/userMission.service.js b/Chapter_mission/src/services/userMission.service.js index 216c61c..8f40214 100644 --- a/Chapter_mission/src/services/userMission.service.js +++ b/Chapter_mission/src/services/userMission.service.js @@ -1,25 +1,32 @@ // src/services/userMission.service.js -import { findActiveChallenge, - addUserMission, - getUserMissionById, - findActiveMissionsByUserId, - updateUserMissionStatus, } from "../repositories/userMission.repository.js"; -import { responseFromUserMission, } from "../dtos/userMission.dto.js"; -import { MissionAlreadyCompletedError, - MissionAlreadyInProgressError, - MissionNotFoundError } from "../error.js"; +import { + findActiveChallenge, + addUserMission, + getUserMissionById, + findActiveMissionsByUserId, + updateUserMissionStatus, +} from "../repositories/userMission.repository.js"; +import { responseFromUserMission } from "../dtos/userMission.dto.js"; +import { + MissionAlreadyCompletedError, + MissionAlreadyInProgressError, + MissionNotFoundError, +} from "../error.js"; import { MissionStatus } from "@prisma/client"; +import { ForbiddenError } from "../error.js"; // 미션 도전 -export const challengeMission = async (userId, missionId) => { +export const challengeMission = async ({ userId, missionId }) => { // 이미 도전 중인지 확인 const existing = await findActiveChallenge(userId, missionId); if (existing) { throw new MissionAlreadyInProgressError("이미 도전 중인 미션입니다."); } - // 결과 반환 + // 유저 미션 생성 const challenge = await addUserMission(userId, missionId); + + // DTO로 응답 정제 return responseFromUserMission(challenge); }; @@ -39,21 +46,34 @@ export const listActiveMissions = async (userId) => { }; // 미션 완료 -export const completeUserMission = async (userMissionId) => { - // 현재 도전 상태 확인 +export const completeUserMission = async (userId, userMissionId) => { const mission = await getUserMissionById(userMissionId); if (!mission) { - throw new MissionNotFoundError("해당 미션 도전 정보를 찾을 수 없습니다."); + throw new MissionNotFoundError("해당 미션 도전 정보를 찾을 수 없습니다.", { + userMissionId, + }); + } + + // 소유권 검사 + if (String(mission.userId) !== String(userId)) { + throw new ForbiddenError("본인의 미션만 완료할 수 있습니다.", { + userMissionId, + ownerUserId: String(mission.userId), + requesterId: String(userId), + }); } - if (mission.status == "COMPLETED") { - throw new MissionAlreadyCompletedError("이미 완료된 미션입니다."); + if (mission.status === MissionStatus.COMPLETED) { + throw new MissionAlreadyCompletedError("이미 완료된 미션입니다.", { + userMissionId, + }); } - // 상태 업데이트 - const updated = await updateUserMissionStatus(userMissionId, MissionStatus.COMPLETED); + const updated = await updateUserMissionStatus( + userMissionId, + MissionStatus.COMPLETED + ); - // 응답 반환 return responseFromUserMission(updated); }; \ No newline at end of file diff --git a/Chapter_mission/test.html b/Chapter_mission/test.html new file mode 100644 index 0000000..688a4eb --- /dev/null +++ b/Chapter_mission/test.html @@ -0,0 +1,78 @@ + + + + + 내 API 테스트하기 + + +

회원가입 테스트

+ + + + + + \ No newline at end of file