diff --git a/.github/workflows/deploy-main.yml b/.github/workflows/deploy-main.yml new file mode 100644 index 0000000..edc2459 --- /dev/null +++ b/.github/workflows/deploy-main.yml @@ -0,0 +1,83 @@ +name: deploy-main + +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_ZERO" > ~/.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", @@ -1589,6 +1601,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", @@ -1662,6 +1680,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", @@ -1697,6 +1783,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", @@ -2237,6 +2328,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/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -2253,6 +2350,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/validator": { "version": "13.15.23", "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.23.tgz", diff --git a/package.json b/package.json index d9330f6..9de268a 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,9 @@ "http-status-codes": "^2.3.0", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.1", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", + "passport-jwt": "^4.0.1", "prisma": "^6.19.0", "swagger-autogen": "^2.23.7", "swagger-jsdoc": "^6.2.8", diff --git a/src/auth.config.js b/src/auth.config.js new file mode 100644 index 0000000..4117704 --- /dev/null +++ b/src/auth.config.js @@ -0,0 +1,174 @@ +import dotenv from "dotenv"; +import { Strategy as GoogleStrategy } from "passport-google-oauth20"; +import { prisma } from "./db.config.js"; +import jwt from "jsonwebtoken"; // JWT 생성을 위해 import + +dotenv.config(); +const secret = process.env.JWT_SECRET; // .env의 비밀 키 + +export const generateAccessToken = (user) => { + return jwt.sign( + { id: user.id, email: user.email }, + secret, + { expiresIn: '1h' } + ); +}; + +export const generateRefreshToken = (user) => { + return jwt.sign( + { id: 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 defaultProfileImage = 'https://example.com/default-profile.png'; +*/ + const created = await prisma.user.create({ + data: { + email, + name: profile.displayName, + gender: "Other", // 기본값을 Other로 설정 (Male/Female/Other) + birth: new Date(2000, 0, 1), // 기본 생년월일 설정 (2000-01-01) + address: "", + detailAddress: "", + phoneNumber: "", + profileImage: profile.photos?.[0]?.value || defaultProfileImage, + password: "", // 소셜 로그인 사용자는 비밀번호 없음 + // 선호 카테고리 초기화 (빈 배열로 생성) + preferences: { + create: [] + } + }, + include: { + preferences: { + include: { + foodCategory: true + } + } + } + }); + + 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: "/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); + } + } +); + +import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt'; + +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: payload.id } }); + + if (user) { + return done(null, user); + } else { + return done(null, false); + } + } catch (error) { + return done(error, false); + } +}); + +/** + * JWT 인증 미들웨어 + * 인증이 필요한 라우트에 사용 + */ +export const authenticateJWT = (req, res, next) => { + return passport.authenticate('jwt', { session: false }, (err, user, info) => { + if (err) { + console.error('JWT 인증 오류:', err); + return res.status(500).json({ + success: false, + message: '인증 처리 중 오류가 발생했습니다.' + }); + } + + if (!user) { + return res.status(401).json({ + success: false, + message: '로그인이 필요합니다.' + }); + } + + // 인증된 사용자 정보를 req.user에 저장 + req.user = user; + next(); + })(req, res, next); +}; + +/** + * 관리자 권한 확인 미들웨어 + * 관리자만 접근 가능한 라우트에 사용 + */ +export const requireAdmin = (req, res, next) => { + // authenticateJWT 미들웨어를 먼저 통과해야 함 + if (!req.user) { + return res.status(401).json({ + success: false, + message: '인증이 필요합니다.' + }); + } + + // 여기서는 간단히 isAdmin 플래그로 관리자 여부를 확인 + // 실제 구현에서는 사용자 역할(role)을 확인하는 로직으로 대체해야 함 + if (req.user.isAdmin) { + return next(); + } + + return res.status(403).json({ + success: false, + message: '관리자 권한이 필요합니다.' + }); +}; \ No newline at end of file diff --git a/src/controllers/mission.controller.js b/src/controllers/mission.controller.js index 8f146b9..12ac507 100644 --- a/src/controllers/mission.controller.js +++ b/src/controllers/mission.controller.js @@ -1,7 +1,7 @@ import * as missionService from '../services/mission.service.js'; import { StatusCodes } from 'http-status-codes'; -// Error handling with standard Error and status codes +// 표준 Error와 상태 코드를 사용한 에러 처리 class AppError extends Error { constructor(message, statusCode) { super(message); @@ -343,10 +343,22 @@ export const handleAddMission = async (req, res, next) => { */ export const getUserMissions = async (req, res, next) => { try { - const userId = parseInt(req.params.userId); - const missions = await missionService.getUserMissions(userId); + const userId = req.user.id; // Use authenticated user's ID + const { status } = req.query; + const page = parseInt(req.query.page) || 1; + const limit = parseInt(req.query.limit) || 10; - res.success(missions); + const result = await missionService.getUserMissions(userId, { status, page, limit }); + + res.success({ + data: result.missions, + pagination: { + totalItems: result.total, + totalPages: Math.ceil(result.total / limit), + currentPage: page, + itemsPerPage: limit + } + }); } catch (error) { next(error); } @@ -518,10 +530,17 @@ export const getUserMissions = async (req, res, next) => { */ export const completeUserMission = async (req, res, next) => { try { - const { userId, missionId } = req.params; - const result = await missionService.completeMission(parseInt(userId), parseInt(missionId)); + const userId = req.user.id; // Use authenticated user's ID + const { missionId } = req.params; - res.success(result, '미션이 성공적으로 완료되었습니다.'); + const result = await missionService.completeMission(userId, parseInt(missionId)); + + res.success({ + id: result.id, + status: result.status, + completedAt: result.completedAt, + rewardEarned: result.rewardEarned + }, '미션이 성공적으로 완료되었습니다!'); } catch (error) { next(error); } @@ -708,7 +727,7 @@ export const completeUserMission = async (req, res, next) => { */ export const assignMissionToUser = async (req, res, next) => { try { - const userId = parseInt(req.params.userId); + const userId = req.user.id; // Use authenticated user's ID const { missionId } = req.body; if (!missionId) { @@ -875,10 +894,21 @@ export const assignMissionToUser = async (req, res, next) => { */ export const getUserReviews = async (req, res, next) => { try { - const userId = parseInt(req.params.userId); - const reviews = await missionService.getUserReviews(userId); + const userId = req.user.id; // Use authenticated user's ID + const page = parseInt(req.query.page) || 1; + const limit = parseInt(req.query.limit) || 10; + + const result = await missionService.getUserReviews(userId, { page, limit }); - res.success(reviews); + res.success({ + data: result.reviews, + pagination: { + totalItems: result.total, + totalPages: Math.ceil(result.total / limit), + currentPage: page, + itemsPerPage: limit + } + }); } catch (error) { next(error); } @@ -888,7 +918,7 @@ export const getUserReviews = async (req, res, next) => { * @swagger * /api/v1/stores/{storeId}/missions: * get: - * summary: Get all missions for a store + * summary: 가게의 모든 미션 조회 * tags: [Missions] * parameters: * - in: path @@ -896,11 +926,13 @@ export const getUserReviews = async (req, res, next) => { * required: true * schema: * type: integer + * minimum: 1 + * description: 미션을 조회할 가게의 고유 ID * responses: * 200: - * description: List of store's missions + * description: 가게의 미션 목록이 성공적으로 조회됨 * 500: - * description: Internal server error + * description: 서버 내부 오류 발생 */ /** * @swagger @@ -962,7 +994,7 @@ export const getUserReviews = async (req, res, next) => { * type: string * example: "미션 목록 조회 중 오류가 발생했습니다." */ -export const getStoreMissions = async (req, res, next) => { +export const getMissionsByStore = async (req, res, next) => { try { const storeId = parseInt(req.params.storeId); const missions = await missionService.getMissionsByStoreId(storeId); diff --git a/src/controllers/review.controller.js b/src/controllers/review.controller.js index fead75e..abffd1f 100644 --- a/src/controllers/review.controller.js +++ b/src/controllers/review.controller.js @@ -3,11 +3,11 @@ import * as reviewService from '../services/review.service.js'; /** * POST /api/v1/stores/{storeId}/reviews 엔드포인트 핸들러 */ -export const handleAddReview = async (req, res) => { +export const addReview = async (req, res) => { // URL 경로에서 storeId 획득 const storeId = parseInt(req.params.storeId); - // 인증된 사용자 ID (현재는 ID 1로 가정) - const userId = 1; + // 인증된 사용자 ID 사용 + const userId = req.user.id; const { rating, content } = req.body; if (!rating || !content) { diff --git a/src/controllers/store.controller.js b/src/controllers/store.controller.js index e2a7343..3f49bed 100644 --- a/src/controllers/store.controller.js +++ b/src/controllers/store.controller.js @@ -67,25 +67,30 @@ import { NotFoundError } from '../errors.js'; * type: string * example: "가게 추가 중 오류가 발생했습니다." */ -export const handleAddStore = async (req, res) => { - const { name, address, region } = req.body; - - if (!name || !address || !region) { - return res.status(400).json({ message: '모든 가게 정보를 입력해야 합니다.' }); - } - +export const handleAddStore = async (req, res, next) => { try { - const result = await storeService.addNewStore({ name, address, region }); - return res.status(201).json(result); // 201 Created - } catch (error) { - if (error instanceof NotFoundError) { - throw error; + const { name, address, region } = req.body; + + if (!name || !address || !region) { + return res.status(StatusCodes.BAD_REQUEST).json({ + success: false, + message: '모든 가게 정보를 입력해야 합니다.' + }); } - console.error('가게 추가 중 오류 발생:', error); - return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ - message: '가게 추가 중 오류가 발생했습니다.', - error: process.env.NODE_ENV === 'development' ? error.message : undefined + + const result = await storeService.addNewStore({ + name, + address, + region + }); + + return res.status(StatusCodes.CREATED).json({ + success: true, + message: '가게가 성공적으로 등록되었습니다.', + data: result }); + } catch (error) { + next(error); } }; @@ -289,19 +294,14 @@ export const getStoreById = async (req, res, next) => { try { const store = await getStoreByIdService(req.params.storeId); if (!store) { - return res.status(StatusCodes.NOT_FOUND).json({ - success: false, - message: '가게를 찾을 수 없습니다.' - }); + const error = new Error('가게를 찾을 수 없습니다.'); + error.statusCode = StatusCodes.NOT_FOUND; + throw error; } - res.status(StatusCodes.OK).json({ - success: true, - data: store - }); + return res.success(store); } catch (error) { - console.error('Error listing store reviews:', error); - res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ message: '가게 리뷰 조회 중 오류가 발생했습니다.' }); + next(error); } }; @@ -424,47 +424,49 @@ export const getStoreMissions = async (req, res, next) => { * example: "리뷰 작성 중 오류가 발생했습니다." */ export const handleCreateStoreReview = async (req, res, next) => { - try { - const { content, rating, userId } = req.body; - const storeId = parseInt(req.params.storeId); + try { + const userId = req.user.id; + const { content, rating } = req.body; + const storeId = parseInt(req.params.storeId); - // 테스트를 위해 인증 검사 일시 비활성화 - // if (!userId) { - // return res.status(StatusCodes.UNAUTHORIZED).json({ - // message: '로그인이 필요합니다.' - // }); - // } + if (!content || !rating) { + return res.status(StatusCodes.BAD_REQUEST).json({ + success: false, + message: '리뷰 내용과 평점은 필수입니다.' + }); + } - const review = await storeService.createStoreReview({ - content, - rating, - userId, - storeId - }); + const review = await storeService.createStoreReview({ + content, + rating, + userId, + storeId + }); - res.status(StatusCodes.CREATED).json({ - message: '리뷰가 성공적으로 등록되었습니다.', - data: review - }); - } catch (error) { - console.error('Error creating store review:', error); - - if (error.message.includes('가게를 찾을 수 없습니다')) { - return res.status(StatusCodes.NOT_FOUND).json({ - message: error.message - }); - } - - if (error.message.includes('필수') || error.message.includes('평점')) { - return res.status(StatusCodes.BAD_REQUEST).json({ - message: error.message - }); + return res.status(StatusCodes.CREATED).json({ + success: true, + message: '리뷰가 성공적으로 등록되었습니다.', + data: review + }); + } catch (error) { + console.error('Error creating store review:', error); + + if (error.message.includes('가게를 찾을 수 없습니다')) { + return res.status(StatusCodes.NOT_FOUND).json({ + success: false, + message: error.message + }); + } + + if (error.message.includes('필수') || error.message.includes('평점')) { + return res.status(StatusCodes.BAD_REQUEST).json({ + success: false, + message: error.message + }); + } + + next(error); } - - res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ - message: '리뷰 등록 중 오류가 발생했습니다.' - }); - } }; // handleAddReview는 handleCreateStoreReview의 별칭으로 사용 diff --git a/src/controllers/user.controller.js b/src/controllers/user.controller.js index a63fb20..33abb0e 100644 --- a/src/controllers/user.controller.js +++ b/src/controllers/user.controller.js @@ -1,11 +1,59 @@ import { StatusCodes } from 'http-status-codes'; import { bodyToUser } from '../dtos/user.dto.js'; -import { userSignUp } from '../services/user.service.js'; +import { userSignUp, updateUser } from '../services/user.service.js'; import { ValidationError } from '../errors.js'; import { prisma } from '../db.config.js'; /** * @swagger + * components: + * schemas: + * User: + * type: object + * properties: + * id: + * type: integer + * description: 사용자 고유 ID + * example: 1 + * email: + * type: string + * format: email + * description: 사용자 이메일 + * example: "user@example.com" + * name: + * type: string + * description: 사용자 이름 + * example: "홍길동" + * gender: + * type: string + * enum: [MALE, FEMALE, OTHER] + * description: 성별 + * example: "MALE" + * birth: + * type: string + * format: date + * description: 생년월일 (YYYY-MM-DD) + * example: "1990-01-01" + * address: + * type: string + * description: 기본 주소 + * example: "서울특별시 강남구 테헤란로 123" + * detailAddress: + * type: string + * description: 상세 주소 + * example: "101동 101호" + * phoneNumber: + * type: string + * nullable: true + * description: 휴대폰 번호 ('-' 제외) + * example: "01012345678" + * preferences: + * type: array + * description: 선호 카테고리 목록 + * items: + * type: string + * example: ["한식", "중식"] + * * /api/users/signup: * post: * summary: 사용자 회원가입 @@ -162,6 +210,88 @@ export const handleUserSignUp = async (req, res, next) => { } }; +/** + * @swagger + * /api/v1/users/me: + * put: + * tags: [User] + * summary: 사용자 정보 수정 + * description: 인증된 사용자의 정보를 업데이트합니다. + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * description: 사용자 이름 + * gender: + * type: string + * enum: [MALE, FEMALE, OTHER] + * description: 사용자 성별 + * birth: + * type: string + * format: date + * description: 사용자 생년월일 (YYYY-MM-DD 형식) + * address: + * type: string + * description: 사용자 주소 + * detailAddress: + * type: string + * description: 사용자 상세 주소 + * phoneNumber: + * type: string + * description: 사용자 전화번호 (하이픈 없이 입력) + * preferences: + * type: array + * items: + * type: string + * enum: [한식, 일식, 중식, 양식, 치킨, 분식, 고기/구이, 도시락, 야식, 패스트푸드, 디저트, 아시안푸드] + * description: 사용자 음식 선호도 + * responses: + * 200: + * description: 사용자 정보가 성공적으로 업데이트됨 + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/User' + * 400: + * description: 잘못된 입력 데이터 + * 401: + * description: 인증 실패 + * 404: + * description: 사용자를 찾을 수 없음 + * 500: + * description: 서버 내부 오류 + */ +export const updateMyProfile = async (req, res, next) => { + try { + const userId = req.user.id; + const updateData = req.body; + + // Validate input data + if (!updateData || Object.keys(updateData).length === 0) { + throw new ValidationError('업데이트할 정보를 입력해주세요.'); + } + + // Update user information + const updatedUser = await updateUser(userId, updateData); + + res.status(StatusCodes.OK).json({ + success: true, + message: '사용자 정보가 성공적으로 업데이트되었습니다.', + data: updatedUser + }); + } catch (error) { + console.error('사용자 정보 업데이트 중 오류 발생:', error); + next(error); + } +}; + export const signUp = async (req, res, next) => { try { console.log('\n=== 회원가입 요청 시작 ==='); diff --git a/src/controllers/userChallenge.controller.js b/src/controllers/userChallenge.controller.js index 350049e..bfdf50e 100644 --- a/src/controllers/userChallenge.controller.js +++ b/src/controllers/userChallenge.controller.js @@ -4,8 +4,8 @@ import * as challengeService from '../services/userChallenge.service.js'; * POST /api/v1/users/{userId}/challenges 엔드포인트 핸들러 */ export const handleChallengeMission = async (req, res) => { - // URL 경로에서 userId 획득 (현재는 ID 1로 가정) - const userId = 1; + // 인증된 사용자 ID 사용 + const userId = req.user.id; // Body에서 도전할 미션 ID 획득 const { missionId } = req.body; diff --git a/src/dtos/user.dto.js b/src/dtos/user.dto.js index 581e13c..8cfd6b2 100644 --- a/src/dtos/user.dto.js +++ b/src/dtos/user.dto.js @@ -45,9 +45,6 @@ export const bodyToUser = (body) => { - - - export const responseFromUser = ({ user, preferences = [] }) => { const preferFoods = preferences.length > 0 ? preferences.map((preference) => diff --git a/src/index.js b/src/index.js index e94dc6f..3ec446a 100644 --- a/src/index.js +++ b/src/index.js @@ -5,12 +5,15 @@ import path from 'path'; import { fileURLToPath } from 'url'; import swaggerJsdoc from 'swagger-jsdoc'; import swaggerUiExpress from "swagger-ui-express"; +import passport from "passport"; +import { googleStrategy, jwtStrategy } from "./auth.config.js"; +import { prisma } from "./db.config.js"; -// Get the current directory name in ES module +// ES 모듈에서 현재 디렉토리 이름 가져오기 const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -// Load .env file from the root directory +// 루트 디렉토리에서 .env 파일 로드 const envPath = path.resolve(__dirname, '../../.env'); try { dotenv.config({ path: envPath }); @@ -21,10 +24,12 @@ try { import morgan from 'morgan'; import cookieParser from 'cookie-parser'; -import { prisma } from './db.config.js'; + +// 미들웨어 임포트 +import { authenticateJWT, requireAdmin } from './auth.config.js'; // 컨트롤러 임포트 -import { signUp } from './controllers/user.controller.js'; +import { signUp, updateMyProfile } from './controllers/user.controller.js'; import { handleAddStore, handleListStoreReviews, @@ -43,8 +48,9 @@ import { handleChallengeMission } from './controllers/mission.controller.js'; -// .env 파일 로드 -dotenv.config(); +// Passport 설정 +passport.use(googleStrategy); +passport.use(jwtStrategy); // Prisma 클라이언트 연결 확인 async function checkDatabaseConnection() { @@ -71,7 +77,7 @@ if (process.env.NODE_ENV !== 'production') { // 기본 미들웨어 설정 app.use(express.json()); -app.use(express.urlencoded({ extended: true })); +app.use(express.urlencoded({ extended: false })); // 원래 true였음 app.use(cookieParser()); app.use(cors({ origin: [ @@ -81,6 +87,7 @@ app.use(cors({ credentials: true })); app.use(express.static('public')); // 정적 파일 제공 +app.use(passport.initialize()); // 성공/에러 응답 메서드 추가 app.use((req, res, next) => { @@ -116,25 +123,31 @@ app.use((req, res, next) => { // 4. 라우트 설정 // 사용자 관련 라우트 app.post('/api/v1/users/signup', signUp); +app.put('/api/v1/users/me', authenticateJWT, updateMyProfile); // 가게 관련 라우트 app.get('/api/v1/stores/:storeId', getStoreById); -app.post('/api/v1/stores', handleAddStore); +app.post('/api/v1/stores', authenticateJWT, handleAddStore); + +// 가게 리뷰 관련 라우트 app.get('/api/v1/stores/:storeId/reviews', handleListStoreReviews); -app.post('/api/v1/stores/:storeId/reviews', handleCreateStoreReview); // 미션 관련 라우트 app.get('/api/v1/stores/:storeId/missions', getStoreMissions); -app.get('/api/v1/users/:userId/missions', getUserMissions); -app.patch('/api/v1/users/:userId/missions/:missionId/complete', completeUserMission); -app.post('/api/v1/users/:userId/missions', assignMissionToUser); -app.get('/api/v1/users/:userId/reviews', getUserReviews); +app.get('/api/v1/users/:userId/missions', authenticateJWT, getUserMissions); +app.patch('/api/v1/users/:userId/missions/:missionId/complete', authenticateJWT, completeUserMission); +app.post('/api/v1/users/:userId/missions', authenticateJWT, assignMissionToUser); + +// 리뷰 관련 라우트 +app.get('/api/v1/users/:userId/reviews', authenticateJWT, getUserReviews); +app.post('/api/v1/reviews', authenticateJWT, handleCreateStoreReview); +app.post('/api/v1/stores/:storeId/reviews', authenticateJWT, handleCreateStoreReview); // 미션 도전 관련 라우트 -app.post('/api/v1/missions/:missionId/challenge', handleChallengeMission); +app.post('/api/v1/missions/:missionId/challenge', authenticateJWT, handleChallengeMission); // 미션 추가 (관리자용) -app.post('/api/v1/missions', handleAddMission); +app.post('/api/v1/missions', authenticateJWT, requireAdmin, handleAddMission); // API 상태 확인을 위한 엔드포인트 app.get('/api/health', (req, res) => { @@ -230,6 +243,34 @@ const options = { apis: ["./src/**/*.js"] }; + +/* 9주차실습시작 */ +app.get("/oauth2/login/google", + passport.authenticate("google", { + session: false + }) +); +app.get( + "/oauth2/callback/google", + 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": "..." } + } + }); + } +); +/* 9주차실습끝 */ + const swaggerSpec = swaggerJsdoc(options); // Swagger UI @@ -271,16 +312,16 @@ app.get('/openapi.json', (req, res) => { res.send(swaggerSpec); }); -// Global error handler middleware +// 전역 에러 핸들링 미들웨어 app.use((err, req, res, next) => { console.error('Error:', err); - // If headers are already sent, delegate to the default Express error handler + // 헤더가 이미 전송된 경우 기본 Express 에러 핸들러에 위임 if (res.headersSent) { return next(err); } - // Default error response + // 기본 에러 응답 const statusCode = err.statusCode || 500; const response = { success: false, @@ -296,9 +337,9 @@ app.use((err, req, res, next) => { timestamp: new Date().toISOString() }; - // Handle specific error types + // 특정 에러 유형 처리 switch (true) { - // Validation errors (400) + // 유효성 검사 에러 (400) case err.name === 'ValidationError': case statusCode === 400: response.error.code = 'VALIDATION_ERROR'; @@ -314,7 +355,7 @@ app.use((err, req, res, next) => { response.statusCode = 404; break; - // Authentication errors (401) + // 인증 에러 (401) case err.name === 'UnauthorizedError': case statusCode === 401: response.error.code = 'UNAUTHORIZED'; @@ -322,7 +363,7 @@ app.use((err, req, res, next) => { response.statusCode = 401; break; - // Forbidden errors (403) + // 접근 거부 에러 (403) case err.name === 'ForbiddenError': case statusCode === 403: response.error.code = 'FORBIDDEN'; @@ -330,7 +371,7 @@ app.use((err, req, res, next) => { response.statusCode = 403; break; - // Conflict errors (409) + // 충돌 에러 (409) case err.name === 'ConflictError': case statusCode === 409: response.error.code = 'CONFLICT'; @@ -338,51 +379,30 @@ app.use((err, req, res, next) => { response.statusCode = 409; break; - // Rate limiting (429) + // 요청 한도 초과 (429) case err.name === 'RateLimitError': response.error.code = 'RATE_LIMIT_EXCEEDED'; response.error.message = err.message || '요청 한도를 초과했습니다. 잠시 후 다시 시도해주세요.'; response.statusCode = 429; break; - // Default to 500 for unhandled errors + // 처리되지 않은 에러는 기본적으로 500 에러로 처리 default: response.statusCode = 500; response.error.code = 'INTERNAL_SERVER_ERROR'; response.error.message = '서버에서 오류가 발생했습니다.'; - // Don't leak error details in production + // 프로덕션 환경에서는 에러 상세 정보 노출 방지 if (process.env.NODE_ENV !== 'development') { delete response.error.stack; delete response.error.name; } } - // Send the error response + // 에러 응답 전송 res.status(response.statusCode).json(response); }); -// 가게 관련 라우트 -app.get('/api/v1/stores/:storeId', getStoreById); -app.post('/api/v1/stores', handleAddStore); -app.get('/api/v1/stores/:storeId/reviews', handleListStoreReviews); -app.post('/api/v1/stores/:storeId/reviews', handleCreateStoreReview); - -// 미션 관련 라우트 -app.get('/api/v1/stores/:storeId/missions', getStoreMissions); -app.get('/api/v1/users/:userId/missions', getUserMissions); -app.patch('/api/v1/users/:userId/missions/:missionId/complete', completeUserMission); -app.post('/api/v1/users/:userId/missions', assignMissionToUser); -app.get('/api/v1/users/:userId/reviews', getUserReviews); - -// 리뷰 관련 라우트 -app.post('/api/v1/reviews', handleCreateStoreReview); - -// 미션 도전 관련 라우트 -app.post('/api/v1/missions/:missionId/challenge', handleChallengeMission); - -// 미션 추가 (관리자용) -app.post('/api/v1/missions', handleAddMission); // 서버 시작 async function startServer() { @@ -463,24 +483,8 @@ startServer().catch(error => { app.get("/api/v1/stores/:storeId/reviews", handleListStoreReviews); - - -//7주차 시작 -const isLogin = (req, res, next) => { - // cookie-parser가 만들어준 req.cookies 객체에서 username을 확인 - const { username } = req.cookies; - - if (username) { - - console.log(`[인증 성공] ${username}님, 환영합니다.`); - next(); - } else { - - console.log('[인증 실패] 로그인이 필요합니다.'); - res.status(401).send(''); - } -}; - +// JWT 인증 미들웨어 +const isLogin = passport.authenticate('jwt', { session: false }); app.get('/', (req, res) => { res.send(` @@ -497,16 +501,18 @@ app.get('/login', (req, res) => { res.send('

로그인 페이지

로그인이 필요한 페이지에서 튕겨나오면 여기로 옵니다.

'); }); - app.get('/mypage', isLogin, (req, res) => { - res.send(` -

마이페이지

-

환영합니다, ${req.cookies.username}님!

-

이 페이지는 로그인한 사람만 볼 수 있습니다.

- `); + res.status(200).json({ + success: true, + message: `인증 성공! ${req.user.name}님의 마이페이지입니다.`, + data: { + user: req.user + } + }); }); + app.get('/set-login', (req, res) => { res.cookie('username', 'UMC9th', { maxAge: 3600000 }); res.send('로그인 쿠키(username=UMC9th) 생성 완료! 마이페이지로 이동'); diff --git a/src/services/user.service.js b/src/services/user.service.js index d5bd3c5..ffbead5 100644 --- a/src/services/user.service.js +++ b/src/services/user.service.js @@ -219,12 +219,96 @@ function mapPreferencesToCategoryIds(preferences = []) { * @param {Object} user - 사용자 객체 * @returns {Object} 민감 정보가 제거된 사용자 객체 */ -function excludeSensitiveData(user) { +export const excludeSensitiveData = (user) => { if (!user) return null; const { password, ...userWithoutPassword } = user; return userWithoutPassword; -} +}; + +/** + * 사용자 정보 업데이트 + * @param {number} userId - 업데이트할 사용자 ID + * @param {Object} updateData - 업데이트할 사용자 정보 + * @returns {Promise} 업데이트된 사용자 정보 + * @throws {NotFoundError} 사용자를 찾을 수 없을 때 + * @throws {ValidationError} 유효성 검사 실패 시 + */ +export const updateUser = async (userId, updateData) => { + try { + // 1. 사용자 존재 여부 확인 + const existingUser = await prisma.user.findUnique({ + where: { id: userId }, + include: { + preferences: true + } + }); + + if (!existingUser) { + throw new NotFoundError('사용자를 찾을 수 없습니다.'); + } + + // 2. 업데이트할 데이터 준비 + const dataToUpdate = { + name: updateData.name, + gender: updateData.gender, + birth: updateData.birth ? new Date(updateData.birth) : null, + address: updateData.address, + detailAddress: updateData.detailAddress || null, + phoneNumber: updateData.phoneNumber || null + }; + + // 3. 선호 카테고리 업데이트 + let foodCategoryIds = []; + if (updateData.preferences && updateData.preferences.length > 0) { + foodCategoryIds = mapPreferencesToCategoryIds(updateData.preferences); + } + + // 4. 트랜잭션으로 사용자 정보와 선호 카테고리 업데이트 + const [updatedUser] = await prisma.$transaction([ + // 사용자 정보 업데이트 + prisma.user.update({ + where: { id: userId }, + data: dataToUpdate, + include: { + preferences: { + include: { + foodCategory: true + } + } + } + }), + // 기존 선호 카테고리 삭제 + prisma.userFavorCategory.deleteMany({ + where: { userId } + }), + // 새로운 선호 카테고리 추가 + ...(foodCategoryIds.length > 0 ? [ + prisma.userFavorCategory.createMany({ + data: foodCategoryIds.map(categoryId => ({ + userId, + foodCategoryId: categoryId + })) + }) + ] : []) + ]); + + // 5. 업데이트된 사용자 정보 조회 (선호 카테고리 포함) + const userWithPreferences = await getUser(userId); + + // 6. 민감 정보 제거 후 반환 + return excludeSensitiveData(userWithPreferences); + + } catch (error) { + console.error('사용자 정보 업데이트 중 오류 발생:', error); + + if (error.code === 'P2002') { + throw new ValidationError('이미 사용 중인 이메일입니다.'); + } + + throw error; + } +}; /** * 이메일로 사용자 조회 @@ -233,9 +317,19 @@ function excludeSensitiveData(user) { */ export const getUserByEmail = async (email) => { try { - const user = await getUser(email); + // 비밀번호 포함하여 사용자 정보 조회 (excludeSensitiveData 호출 제거) + const user = await prisma.user.findUnique({ + where: { email }, + include: { + preferences: { + include: { + foodCategory: true + } + } + } + }); - return user ? excludeSensitiveData(user) : null; + return user || null; } catch (error) { console.error('사용자 조회 중 오류 발생:', error); throw new InternalServerError('사용자 정보를 가져오는 중 오류가 발생했습니다.'); @@ -264,5 +358,6 @@ export const authenticateUser = async (email, password) => { ]); } + // 인증 성공 시에만 민감한 정보 제거 return excludeSensitiveData(user); }; \ No newline at end of file