diff --git a/.github/workflows/deploy-main-hyeon.yml b/.github/workflows/deploy-main-hyeon.yml new file mode 100644 index 0000000..949aead --- /dev/null +++ b/.github/workflows/deploy-main-hyeon.yml @@ -0,0 +1,83 @@ +name: deploy-main-hyeon + +on: + push: + branches: + - 현/main + - feature/mission-10/현 + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + # Prisma 폴더가 변경되었는지 감지하는 단계 + - name: Check prisma has changes + uses: dorny/paths-filter@v3 + id: paths-filter + with: + filters: | + prisma: ["prisma/**"] + + - name: Configure SSH + run: | + mkdir -p ~/.ssh + echo "$EC2_SSH_KEY" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + + # 접속 별명 설정 + cat >>~/.ssh/config <=6.0.0" + } + }, "node_modules/basic-auth": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", @@ -226,6 +239,12 @@ "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", @@ -511,6 +530,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", @@ -936,6 +964,91 @@ "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", @@ -1164,6 +1277,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", @@ -1230,6 +1349,74 @@ "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", @@ -1255,6 +1442,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", @@ -1430,6 +1622,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", @@ -1633,6 +1837,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", @@ -1642,6 +1852,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/package.json b/package.json index 4f06dc8..1e16b77 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,12 @@ "dotenv": "^17.2.3", "express": "^5.1.0", "http-status-codes": "^2.3.0", + "jsonwebtoken": "^9.0.2", "morgan": "^1.10.1", "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/src/auth.config.js b/src/auth.config.js new file mode 100644 index 0000000..101e800 --- /dev/null +++ b/src/auth.config.js @@ -0,0 +1,109 @@ +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: Number(user.id), email: user.email }, secret, { + expiresIn: "1h", + }); +}; + +export const generateRefreshToken = (user) => { + return jwt.sign({ id: Number(user.id) }, secret, { expiresIn: "14d" }); +}; + +// GoogleVerify +const googleVerify = async (profile) => { + const email = profile.emails?.[0]?.value; + if (!email) { + throw new Error(`profile.email was not found: ${profile}`); + } + + const user = await prisma.user.findFirst({ where: { email } }); + if (user !== null) { + return { id: user.id, email: user.email, name: user.name }; + } + + const created = await prisma.user.create({ + data: { + email, + name: profile.displayName, + gender: null, + birth: new Date(1970, 0, 1), + address: "추후 수정", + specAddress: "추후 수정", + passwordHash: "GOOGLE_LOGIN", + status: null, + inactiveDate: null, + point: 0, + provider: "google", + providerId: profile.id, + }, + }); + + return { id: created.id, email: created.email, name: created.name }; +}; + +// GoogleStrategy + +export const googleStrategy = new GoogleStrategy( + { + clientID: process.env.PASSPORT_GOOGLE_CLIENT_ID, + clientSecret: process.env.PASSPORT_GOOGLE_CLIENT_SECRET, + callbackURL: "/auth/google/callback", + 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 user = await prisma.user.findFirst({ + where: { id: BigInt(payload.id) }, + }); + + if (!user) { + return done(null, false); + } + + const safeUser = { + id: Number(user.id), + email: user.email, + name: user.name, + provider: user.provider, + }; + + return done(null, safeUser); + } catch (err) { + return done(err, false); + } + } +); diff --git a/src/controllers/mission.controller.js b/src/controllers/mission.controller.js index c04dc67..61e58f7 100644 --- a/src/controllers/mission.controller.js +++ b/src/controllers/mission.controller.js @@ -269,7 +269,8 @@ export const handleChallengeMission = async (req, res, next) => { } */ try { - const um = await challengeMission(Number(req.params.missionId)); + const userId = req.user.id; + const um = await challengeMission(userId, Number(req.params.missionId)); res.status(StatusCodes.CREATED).success(responseFromUserMission(um)); } catch (err) { next(err); diff --git a/src/controllers/review.controller.js b/src/controllers/review.controller.js index 8d5089b..6ea5438 100644 --- a/src/controllers/review.controller.js +++ b/src/controllers/review.controller.js @@ -159,6 +159,14 @@ export const handleListMyReviews = async (req, res, next) => { #swagger.summary = '내 리뷰 목록 조회 API' #swagger.description = '현재 로그인된 사용자의 리뷰 목록을 조회' + #swagger.parameters['userId'] = { + in: 'query', + required: true, + description: '내 리뷰를 조회할 사용자 ID (임시: 인증 미구현으로 query 사용)', + schema: { type: 'number' }, + example: 1 + } + #swagger.parameters['cursor'] = { in: 'query', required: false, @@ -220,6 +228,7 @@ export const handleListMyReviews = async (req, res, next) => { } } } + #swagger.responses[404] = { description: "유저가 존재하지 않는 경우", content: { @@ -250,7 +259,16 @@ export const handleListMyReviews = async (req, res, next) => { } */ try { - const userIdFromAuth = null; + const rawUserId = req.query.userId; + const userIdFromAuth = + typeof rawUserId === "string" ? Number(rawUserId) : NaN; + + if (!Number.isFinite(userIdFromAuth)) { + throw new ValidationError("userId는 숫자여야 합니다.", { + userId: rawUserId, + }); + } + const cursor = typeof req.query.cursor === "string" ? Number(req.query.cursor) : 0; const take = diff --git a/src/controllers/store.controller.js b/src/controllers/store.controller.js index e2dee71..6042806 100644 --- a/src/controllers/store.controller.js +++ b/src/controllers/store.controller.js @@ -252,7 +252,7 @@ export const handleListStoreReviews = async (req, res, next) => { } */ try { - const storeId = req.params.storeId; + const storeId = Number(req.params.storeId); const cursor = typeof req.query.cursor === "string" ? Number(req.query.cursor) : 0; @@ -405,7 +405,7 @@ export const handleListStoreMissions = async (req, res, next) => { } */ try { - const storeId = req.params.storeId; + const storeId = Number(req.params.storeId); const cursor = typeof req.query.cursor === "string" ? Number(req.query.cursor) : 0; const take = diff --git a/src/controllers/user.controller.js b/src/controllers/user.controller.js index bf4955d..e4c69e4 100644 --- a/src/controllers/user.controller.js +++ b/src/controllers/user.controller.js @@ -1,6 +1,6 @@ import { StatusCodes } from "http-status-codes"; import { bodyToUser } from "../dtos/user.dto.js"; -import { userSignUp } from "../services/user.service.js"; +import { updateMyProfile, userSignUp } from "../services/user.service.js"; export const handleUserSignUp = async (req, res, next) => { /* @@ -127,3 +127,19 @@ export const handleUserSignUp = async (req, res, next) => { next(err); } }; + +export const handleUpdateMyProfile = async (req, res, next) => { + /* + #swagger.tags = ['User'] + #swagger.summary = '내 프로필 수정 API' + #swagger.description = '로그인한 사용자의 기본 정보를 수정합니다.' + */ + try { + const userId = req.user.id; + const updated = await updateMyProfile(userId, req.body); + + res.status(StatusCodes.OK).success(updated); + } catch (err) { + next(err); + } +}; diff --git a/src/index.js b/src/index.js index 14a731c..4bb25fa 100644 --- a/src/index.js +++ b/src/index.js @@ -12,7 +12,10 @@ import { handleCompleteUserMission, handleListUserMissions, } from "./controllers/mission.controller.js"; -import { handleUserSignUp } from "./controllers/user.controller.js"; +import { + handleUpdateMyProfile, + handleUserSignUp, +} from "./controllers/user.controller.js"; import { handleAddReview, handleListMyReviews, @@ -23,9 +26,14 @@ import cookieParser from "cookie-parser"; import swaggerAutogen from "swagger-autogen"; import swaggerUiExpress from "swagger-ui-express"; import { serialize } from "./utils/serialize.js"; +import { googleStrategy, jwtStrategy } from "./auth.config.js"; +import passport from "passport"; dotenv.config(); +passport.use(googleStrategy); +passport.use(jwtStrategy); + const app = express(); const port = process.env.PORT; @@ -36,6 +44,8 @@ app.use(express.static("public")); // 정적 파일 접근 app.use(express.json()); // request의 본문을 json으로 해석할 수 있도록 함 (JSON 형태의 요청 body를 파싱하기 위함) app.use(express.urlencoded({ extended: false })); // 단순 객체 문자열 형태로 본문 데이터 해석 +app.use(passport.initialize()); + app.use( "/docs", swaggerUiExpress.serve, @@ -98,6 +108,41 @@ app.get("/test", (req, res) => { res.send("Hello!"); }); +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.get( + "/auth/google/login", + passport.authenticate("google", { + session: false, + }) +); +app.get( + "/auth/google/callback", + passport.authenticate("google", { + session: false, + failureRedirect: "/login-failed", + }), + (req, res) => { + const tokens = req.user; + + res.status(200).json({ + resultType: "SUCCESS", + error: null, + success: { + message: "Google 로그인 성공!", + tokens: tokens, // { "accessToken": "...", "refreshToken": "..." } + }, + }); + } +); + // 쿠키 만드는 라우터 app.get("/setcookie", (req, res) => { // #swagger.ignore = true @@ -124,22 +169,22 @@ app.get("/getcookie", (req, res) => { app.post("/api/user/signup", handleUserSignUp); // 지역에 가게 추가 -app.post("/api/stores", handleCreateStore); +app.post("/api/stores", isLogin, handleCreateStore); // 가게에 리뷰 추가 -app.post("/api/stores/:storeId/reviews", handleAddReview); +app.post("/api/stores/:storeId/reviews", isLogin, handleAddReview); // 가게에 미션 추가 -app.post("/api/stores/:storeId/missions", handleAddMission); +app.post("/api/stores/:storeId/missions", isLogin, handleAddMission); // 미션 도전하기 -app.post("/api/missions/:missionId/challenge", handleChallengeMission); +app.post("/api/missions/:missionId/challenge", isLogin, handleChallengeMission); // 리뷰 목록 조회 app.get("/api/stores/:storeId/reviews", handleListStoreReviews); // 내 리뷰 목록 조회 -app.get("/api/me/reviews", handleListMyReviews); +app.get("/api/me/reviews", isLogin, handleListMyReviews); // 특정 유저 리뷰 목록 조회 app.get("/api/users/:userId/reviews", handleListUserReviews); @@ -148,14 +193,18 @@ app.get("/api/users/:userId/reviews", handleListUserReviews); app.get("/api/stores/:storeId/missions", handleListStoreMissions); // 내가 진행 중인 미션 목록 조회 -app.get("/api/users/:userId/missions", handleListUserMissions); +app.get("/api/users/:userId/missions", isLogin, handleListUserMissions); // 진행 중 미션 완료 처리 app.patch( "/api/users/:userId/missions/:missionId/complete", + isLogin, handleCompleteUserMission ); +// 내 정보 수정 +app.patch("/api/me/profile", isLogin, handleUpdateMyProfile); + // 에러 핸들러 app.use((err, req, res, next) => { if (res.headersSent) { diff --git a/src/services/mission.service.js b/src/services/mission.service.js index ad9b358..59b3af1 100644 --- a/src/services/mission.service.js +++ b/src/services/mission.service.js @@ -33,20 +33,12 @@ export const addMissionToStore = async (rawBody, storeIdFromPath) => { }; // 미션 도전 -export const challengeMission = async (missionIdFromPath) => { +export const challengeMission = async (userIdFromAuth, missionIdFromPath) => { const missionId = ensureNumber(missionIdFromPath, "missionId"); - const mission = await getMissionById(missionId); if (!mission) throw new MissionNotFoundError(missionId); - // 현재 인증 미구현이라 첫 사용자로 대체 - const userId = await getFirstUserId(); - if (!userId) { - throw new UserNotFoundError( - undefined, - "사용자가 없습니다. 먼저 회원가입을 진행하세요." - ); - } + const userId = ensureNumber(userIdFromAuth, "userId"); const existing = await findUserMission(userId, missionId); if (existing) { diff --git a/src/services/user.service.js b/src/services/user.service.js index b4f6db3..592efe3 100644 --- a/src/services/user.service.js +++ b/src/services/user.service.js @@ -7,8 +7,8 @@ import { setPreference, } from "../repositories/user.repository.js"; import { prisma } from "../db.config.js"; -import { DuplicateUserEmailError } from "../errors.js"; -import { ensureString } from "../utils/validation.js"; +import { DuplicateUserEmailError, ValidationError } from "../errors.js"; +import { ensureNumber, ensureString } from "../utils/validation.js"; export const userSignUp = async (data) => { // 비밀번호 검증 @@ -68,3 +68,34 @@ export const userSignUp = async (data) => { return responseFromUser({ user, preferences }); }; + +export const updateMyProfile = async (userIdFromAuth, body) => { + const userId = ensureNumber(userIdFromAuth, "userId"); + + // 업데이트 가능한 필드만 추출 (부분 수정 허용) + const data = {}; + if (body.name !== undefined) data.name = body.name; + if (body.gender !== undefined) { + data.gender = body.gender === "여성" ? 0 : 1; + } + if (body.birth !== undefined) { + data.birth = new Date(body.birth); + } + if (body.address !== undefined) data.address = body.address; + if (body.specAddress !== undefined) data.specAddress = body.specAddress; + + const updated = await prisma.user.update({ + where: { id: BigInt(userId) }, + data, + }); + + return { + id: Number(updated.id), + email: updated.email, + name: updated.name, + gender: updated.gender, + birth: updated.birth, + address: updated.address, + specAddress: updated.specAddress, + }; +};