diff --git a/Week10/s0-yeon/Mission/src/auth.config.js b/Week10/s0-yeon/Mission/src/auth.config.js new file mode 100644 index 0000000..f2e28ce --- /dev/null +++ b/Week10/s0-yeon/Mission/src/auth.config.js @@ -0,0 +1,107 @@ +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 created = await prisma.user.create({ + data: { + email, + name: profile.displayName, + gender: "추후 수정", + birth: new Date(1970, 0, 1), + address: "추후 수정", + detailAddress: "추후 수정", + phoneNumber: "추후 수정", + }, + }); + + return { id: created.id, email: created.email, name: created.name }; +}; + +// GoogleStrategy + +const GOOGLE_CALLBACK_URL = "http://umc-9th.p-e.kr:3000/oauth2/callback/google"; + +export const googleStrategy = new GoogleStrategy( + { + clientID: process.env.PASSPORT_GOOGLE_CLIENT_ID, + clientSecret: process.env.PASSPORT_GOOGLE_CLIENT_SECRET, + callbackURL: process.env.GOOGLE_CALLBACK_URL || GOOGLE_CALLBACK_URL, + 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 (err) { + return done(err, false); + } +}); \ No newline at end of file diff --git a/Week10/s0-yeon/Mission/src/controllers/mission.controller.js b/Week10/s0-yeon/Mission/src/controllers/mission.controller.js new file mode 100644 index 0000000..f30c38f --- /dev/null +++ b/Week10/s0-yeon/Mission/src/controllers/mission.controller.js @@ -0,0 +1,207 @@ +import { requestToMission, responseFromMission } from "../dtos/mission.dto.js"; +import { addMission, listMissionsByStore } from "../services/mission.service.js"; +import { StatusCodes } from "http-status-codes"; + +export const handleAddMission = async (req, res, next) => { + /* + #swagger.summary = '미션 등록 API'; + + #swagger.parameters['storeId'] = { + in: 'path', + description: '미션을 등록할 가게 ID', + required: true, + type: 'number' + }; + + #swagger.requestBody = { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + region: { type: "string", example: "서울" }, + missionContent: { type: "string", example: "아메리카노 구매 시 스탬프 적립" }, + givePoint: { type: "number", example: 100 }, + price: { type: "number", example: 4500 } + }, + required: ["region", "missionContent", "givePoint", "price"] + } + } + } + }; + + #swagger.responses[201] = { + description: "미션 등록 성공", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "SUCCESS" }, + error: { type: "object", nullable: true, example: null }, + success: { + type: "object", + properties: { + data: { + type: "object", + properties: { + missionId: { type: "number", example: 1 }, + storeId: { type: "number", example: 3 }, + region: { type: "string", example: "서울" }, + missionContent: { type: "string", example: "아메리카노 구매 시 스탬프 적립" }, + givePoint: { type: "number", example: 100 }, + price: { type: "number", example: 4500 }, + createdAt: { type: "string", format: "date-time" } + } + } + } + } + } + } + } + } + }; + + #swagger.responses[400] = { + description: "미션 등록 실패", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "M001" }, + reason: { type: "string", example: "가격은 0보다 커야 합니다." }, + data: { type: "object" } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + }; +*/ + try { + const { storeId } = req.params; + const missionData = requestToMission(req.body, storeId); + + const newMission = await addMission(missionData); + + res.status(StatusCodes.CREATED).success({ + message: "미션 등록 성공", + data: responseFromMission(newMission), + }); + } catch (error) { + next(error); // ✅ 에러 미들웨어로 전달 + } +}; + +// ✅ 2. 특정 가게의 미션 목록 조회 +export const handleListMissionsByStore = async (req, res, next) => { + /* + #swagger.summary = '특정 가게의 미션 목록 조회 API'; + + #swagger.parameters['storeId'] = { + in: 'path', + description: '미션을 조회할 가게 ID', + required: true, + type: 'number' + }; + + #swagger.parameters['cursor'] = { + in: 'query', + description: '커서 기반 페이지네이션 값 (마지막 미션 ID)', + required: false, + type: 'number' + }; + + #swagger.responses[200] = { + description: "미션 목록 조회 성공", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "SUCCESS" }, + error: { type: "object", nullable: true, example: null }, + success: { + type: "object", + properties: { + data: { + type: "array", + items: { + type: "object", + properties: { + missionId: { type: "number", example: 10 }, + region: { type: "string", example: "서울" }, + missionContent: { type: "string", example: "아메리카노 구매 시 100포인트 지급" }, + givePoint: { type: "number", example: 100 }, + price: { type: "number", example: 4500 }, + createdAt: { type: "string", format: "date-time" }, + store: { + type: "object", + properties: { + name: { type: "string", example: "카페 보라" } + } + } + } + } + }, + pagination: { + type: "object", + properties: { + cursor: { type: "number", nullable: true, example: 5 } + } + } + } + } + } + } + } + } + }; + + #swagger.responses[400] = { + description: "잘못된 요청 또는 입력값 오류", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "M002" }, + reason: { type: "string", example: "storeId는 숫자여야 합니다." }, + data: { type: "object" } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + }; +*/ + + try { + const { storeId } = req.params; + const cursor = req.query.cursor ? parseInt(req.query.cursor) : 0; + const missions = await listMissionsByStore(Number(storeId), cursor); + + res.status(StatusCodes.OK).success({ + data: missions, + pagination: { + cursor: missions.length ? missions[missions.length - 1].missionId : null, + } + }); + } catch (error) { + next(error); + } +}; \ No newline at end of file diff --git a/Week10/s0-yeon/Mission/src/controllers/review.controller.js b/Week10/s0-yeon/Mission/src/controllers/review.controller.js new file mode 100644 index 0000000..24700e0 --- /dev/null +++ b/Week10/s0-yeon/Mission/src/controllers/review.controller.js @@ -0,0 +1,101 @@ +import { StatusCodes } from "http-status-codes"; +import { requestToReview, responseFromReview } from "../dtos/review.dto.js"; +import { addReview } from "../services/review.service.js"; + +export const handleAddReview = async (req, res, next) => { + /* + #swagger.summary = '리뷰 등록 API'; + + #swagger.parameters['storeId'] = { + in: 'path', + description: '리뷰를 등록할 가게 ID', + required: true, + type: 'number' + }; + + #swagger.requestBody = { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + title: { type: "string", example: "친절하고 맛집!" }, + content: { type: "string", example: "사장님 친절하시고 음식이 정말 맛있어요!" }, + star: { type: "number", example: 5 } + }, + required: ["title", "content", "star"] + } + } + } + }; + + #swagger.responses[201] = { + description: "리뷰 등록 성공", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "SUCCESS" }, + error: { type: "object", nullable: true, example: null }, + success: { + type: "object", + properties: { + data: { + type: "object", + properties: { + reviewId: { type: "number", example: 1 }, + userId: { type: "number", example: 5 }, + storeId: { type: "number", example: 3 }, + title: { type: "string", example: "친절하고 맛집!" }, + content: { type: "string", example: "사장님 친절하시고 음식이 정말 맛있어요!" }, + star: { type: "number", example: 5 }, + createdAt: { type: "string", format: "date-time" } + } + } + } + } + } + } + } + } + }; + + #swagger.responses[400] = { + description: "리뷰 등록 실패", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "R001" }, + reason: { type: "string", example: "별점은 1~5 사이여야 합니다." }, + data: { type: "object" } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + }; +*/ + try { + const { storeId } = req.params; + const reviewData = requestToReview(req.body, storeId); + + const newReview = await addReview(reviewData); + + res.status(StatusCodes.CREATED).success({ + message: "리뷰 등록 성공", + data: responseFromReview(newReview), + }); + } catch (error) { + next(error); + } +}; diff --git a/Week10/s0-yeon/Mission/src/controllers/store.controller.js b/Week10/s0-yeon/Mission/src/controllers/store.controller.js new file mode 100644 index 0000000..6f897de --- /dev/null +++ b/Week10/s0-yeon/Mission/src/controllers/store.controller.js @@ -0,0 +1,157 @@ +import { requestToStore, responseFromStore } from "../dtos/store.dto.js"; +import { addStore } from "../services/store.service.js"; +import { listStoreReviews } from "../services/store.service.js"; +import { StatusCodes } from "http-status-codes"; + + + +export const handleAddStore = async (req, res, next) => { + /* + #swagger.summary = '가게 등록 API'; + + #swagger.requestBody = { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + name: { type: "string" }, + region: { type: "string" }, + address: { type: "string" } + } + } + } + } + }; + + #swagger.responses[201] = { + description: "가게 등록 성공", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "SUCCESS" }, + error: { type: "object", example: null, nullable: true }, + success: { + type: "object", + properties: { + data: { + type: "object", + properties: { + storeId: { type: "number", example: 1 }, + name: { type: "string", example: "카페 보라" }, + address: { type: "string", example: "서울시 강남구" }, + region: { type: "string", example: "서울" }, + review: { type: "object", nullable: true, example: null }, + totalStar: { type: "number", example: 4.5 }, + createdAt: { type: "string", format: "date-time" } + } + } + } + } + } + } + } + } + }; + + #swagger.responses[400] = { + description: "가게 등록 실패", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "S001" }, + reason: { type: "string", example: "가게 이름은 필수입니다." }, + data: { type: "object" } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + }; + */ + + try { + // 요청 본문(JSON) → DTO 변환 + console.log("🔥 req.body:", req.body); // ✅ body 확인용 로그 + const storeData = requestToStore(req.body); + + // 서비스 계층에서 가게 추가 + const newStore = await addStore(storeData); + + res.status(StatusCodes.CREATED).success({ + message: "가게 등록 성공", + data: responseFromStore(newStore), + }); + } catch (error) { + next(error); + } +}; + +export const handleListStoreReviews = async (req, res, next) => { + /* + #swagger.summary = '상점 리뷰 목록 조회 API'; + + #swagger.parameters['storeId'] = { + in: 'path', + description: '리뷰를 조회할 가게 ID', + required: true, + type: 'number' + }; + + #swagger.parameters['cursor'] = { + in: 'query', + description: '커서 기반 페이지네이션 값 (마지막 리뷰 ID)', + required: false, + type: 'number' + }; + + #swagger.responses[200] = { + description: "상점 리뷰 목록 조회 성공 응답", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "SUCCESS" }, + error: { type: "object", nullable: true, example: null }, + success: { + type: "object", + properties: { + data: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "number" }, + store: { type: "object", properties: { id: { type: "number" }, name: { type: "string" } } }, + user: { type: "object", properties: { id: { type: "number" }, email: { type: "string" }, name: { type: "string" } } }, + content: { type: "string" } + } + } + }, + pagination: { type: "object", properties: { cursor: { type: "number", nullable: true } }} + } + } + } + } + } + } + }; + */ + const reviews = await listStoreReviews( + req.params.storeId, + typeof req.query.cursor === "string" ? parseInt(req.query.cursor) : 0 + ); + res.status(StatusCodes.OK).success(reviews); +}; diff --git a/Week10/s0-yeon/Mission/src/controllers/user.controller.js b/Week10/s0-yeon/Mission/src/controllers/user.controller.js new file mode 100644 index 0000000..7945b89 --- /dev/null +++ b/Week10/s0-yeon/Mission/src/controllers/user.controller.js @@ -0,0 +1,122 @@ +import { StatusCodes } from "http-status-codes"; +import { bodyToUser } from "../dtos/user.dto.js"; +import { userSignUp } from "../services/user.service.js"; + +// 회원가입 컨트롤러 +export const handleUserSignUp = async (req, res, next) => { + /* + #swagger.summary = '회원 가입 API'; + #swagger.requestBody = { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + email: { type: "string" }, + name: { type: "string" }, + gender: { type: "string" }, + birth: { type: "string", format: "date" }, + address: { type: "string" }, + detailAddress: { type: "string" }, + phoneNumber: { type: "string" }, + password: { type: "string"}, + preferences: { type: "array", items: { type: "number" } } + } + } + } + } + }; + #swagger.responses[201] = { + description: "회원 가입 성공 응답", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "SUCCESS" }, + error: { type: "object", nullable: true, example: null }, + success: { + type: "object", + properties: { + email: { type: "string" }, + name: { type: "string" }, + preferCategory: { type: "array", items: { type: "string" } } + } + } + } + } + } + } + }; + #swagger.responses[400] = { + description: "회원 가입 실패 응답", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "U001" }, + reason: { type: "string" }, + data: { type: "object" } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + }; + */ + console.log("📨 회원가입 요청이 들어왔습니다!"); + // console.log("🔥 req.headers.content-type:", req.headers["content-type"]); + console.log("🔥 req.body:", req.body); + + const user = await userSignUp(bodyToUser(req.body)); + + res.status(StatusCodes.CREATED).success(user); +}; + +export const updateMyInfo = async (req, res, next) => { + /** + * #swagger.tags = ["Users"] + * #swagger.summary = "내 정보 수정" + * #swagger.description = "JWT로 인증한 사용자가 자신의 정보를 수정합니다." + */ + + try { + const userId = req.user.userId; // JWT 인증에서 받아온 값 + + const { + name, + phoneNumber, + birth, // "2000-01-01" + gender, + address, + detailAddress, + } = req.body; + + const updatedUser = await prisma.user.update({ + where: { userId }, + data: { + name: name ?? undefined, + phoneNumber: phoneNumber ?? undefined, + birth: birth ? new Date(birth) : undefined, + gender: gender ?? undefined, + address: address ?? undefined, + detailAddress: detailAddress ?? undefined, + }, + }); + + res.success({ + message: "내 정보가 성공적으로 수정되었습니다.", + user: updatedUser, + }); + } catch (error) { + next(error); + } +}; diff --git a/Week10/s0-yeon/Mission/src/controllers/userMission.controller.js b/Week10/s0-yeon/Mission/src/controllers/userMission.controller.js new file mode 100644 index 0000000..cad5e5f --- /dev/null +++ b/Week10/s0-yeon/Mission/src/controllers/userMission.controller.js @@ -0,0 +1,379 @@ +import { requestToUserMission, responseFromUserMission } from "../dtos/userMission.dto.js"; +import { + challengeMission, + listUserMissions, + completeUserMission, +} from "../services/userMission.service.js"; +import { StatusCodes } from "http-status-codes"; + +export const handleChallengeMission = async (req, res,next) => { + /* + #swagger.summary = '미션 도전 등록 API'; + + #swagger.parameters['missionId'] = { + in: 'path', + description: '도전할 미션 ID', + required: true, + type: 'number' + }; + + #swagger.requestBody = { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + userId: { + type: "number", + example: 5, + description: "미션에 도전하는 사용자 ID" + }, + storeId: { + type: "number", + example: 10, + description: "미션이 속한 가게 ID (body에서 함께 전달)" + }, + timeLimit: { + type: "number", + nullable: true, + example: 1800, + description: "미션 수행 제한 시간(초 단위)" + } + }, + required: ["userId", "storeId"] // timeLimit은 선택값 + } + } + } + }; + +#swagger.responses[201] = { + description: "미션 도전 등록 성공", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "SUCCESS" }, + error: { type: "object", example: null, nullable: true }, + success: { + type: "object", + properties: { + data: { + type: "object", + properties: { + userMissionId: { type: "number" }, + userId: { type: "number" }, + missionId: { type: "number" }, + storeId: { type: "number" }, + status: { type: "string", example: "수행중" }, + acceptAt: { type: "string", format: "date-time" }, + timeLimit: { type: "number" }, + doneAt: { type: "string", format: "date-time", nullable: true, example: null }, // ⭐ NEW + createdAt: { type: "string", format: "date-time" } + } + } + } + } + } + } + } + } +}; + + + #swagger.responses[400] = { + description: "미션 도전 등록 실패 (잘못된 요청 / 이미 도전 중)", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "UM001" }, + reason: { type: "string", example: "이미 진행 중인 미션입니다." }, + data: { type: "object" } + } + }, + success: { type: "object", example: null, nullable: true } + } + } + } + } + }; +*/ + + try { + const { missionId } = req.params; + const userMissionData = requestToUserMission(req.body, missionId); + + const newUserMission = await challengeMission(userMissionData); + + res.status(StatusCodes.CREATED).success({ + message: "미션 도전 등록 성공", + data: responseFromUserMission(newUserMission), + }); + } catch (error) { + next(error); + } +}; +// 내가 진행 중인 미션 목록 조회 +export const handleListUserMissions = async (req, res, next) => { + /* + #swagger.summary = '내가 진행 중인 미션 목록 조회 API' + + #swagger.parameters['userId'] = { + in: 'path', + description: '조회할 사용자 ID', + required: true, + type: 'number' + }; + + #swagger.parameters['cursor'] = { + in: 'query', + description: '커서 기반 페이지네이션 값 (마지막 userMissionId)', + required: false, + type: 'number' + }; + + #swagger.responses[200] = { + description: "진행 중인 미션 목록 조회 성공", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "SUCCESS" }, + error: { type: "object", example: null, nullable: true }, + success: { + type: "object", + properties: { + data: { + type: "array", + items: { + type: "object", + properties: { + userMissionId: { type: "number", example: 12 }, + userId: { type: "number", example: 5 }, + missionId: { type: "number", example: 3 }, + status: { type: "string", example: "수행중" }, + acceptAt: { type: "string", format: "date-time" }, + timeLimit: { type: "number", example: 1800 }, + doneAt: { type: "string", format: "date-time", nullable: true, example: null }, + createdAt: { type: "string", format: "date-time" }, + mission: { + type: "object", + properties: { + missionContent: { type: "string", example: "아메리카노 구매 시 100포인트 지급" }, + givePoint: { type: "number", example: 100 }, + price: { type: "number", example: 4500 }, + store: { + type: "object", + properties: { + name: { type: "string", example: "카페 보라" }, + region: { type: "string", example: "서울" } + } + } + } + } + } + } + }, + pagination: { + type: "object", + properties: { + cursor: { type: "number", nullable: true, example: 10 } + } + } + } + } + } + } + } + } + }; + + #swagger.responses[400] = { + description: "잘못된 사용자 ID / 요청 오류", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "UM002" }, + reason: { type: "string", example: "userId는 숫자여야 합니다." }, + data: { type: "object" } + } + }, + success: { type: "object", example: null, nullable: true } + } + } + } + } + }; +*/ + + try { + const { userId } = req.params; + const cursor = req.query.cursor ? Number(req.query.cursor) : null; + const missions = await listUserMissions(Number(userId), cursor); + + res.status(StatusCodes.OK).success({ + message: "진행 중인 미션 목록 조회 성공", + data: missions, + pagination: { + cursor: missions.length + ? missions[missions.length - 1].userMissionId + : null + } + }); + + } catch (error) { + next(error); + } +}; + +// 진행 중 미션 완료 처리 +export const handleCompleteUserMission = async (req, res, next) => { + /* + #swagger.summary = '진행 중인 미션 완료 처리 API' + + #swagger.parameters['userId'] = { + in: 'path', + description: '사용자 ID', + required: true, + type: 'number', + example: 5 + }; + + #swagger.parameters['userMissionId'] = { + in: 'path', + description: '유저 미션 ID (완료로 변경할 대상)', + required: true, + type: 'number', + example: 12 + }; + + #swagger.requestBody = { + required: false, + description: '요청 바디 없음' + }; + + #swagger.responses[200] = { + description: "미션 완료로 상태 변경 성공", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "SUCCESS" }, + error: { type: "object", nullable: true, example: null }, + success: { + type: "object", + properties: { + data: { + type: "object", + properties: { + userMissionId: { type: "number", example: 12 }, + userId: { type: "number", example: 5 }, + missionId: { type: "number", example: 3 }, + storeId: { type: "number", example: 10 }, + status: { type: "string", example: "완료" }, + acceptAt: { type: "string", format: "date-time", example: "2025-11-16T14:30:00.000Z" }, + timeLimit: { type: "number", example: 1800 }, + doneAt: { type: "string", nullable: true, format: "date-time", example: "2025-11-16T15:15:22.000Z" }, + createdAt: { type: "string", format: "date-time", example: "2025-11-16T13:50:00.000Z" } + } + } + } + } + } + } + } + } + }; + + #swagger.responses[400] = { + description: "요청 파라미터 오류 (userId 또는 userMissionId 잘못됨)", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "UM003" }, + reason: { + type: "string", + example: "유효하지 않은 사용자 ID입니다." + }, + data: { type: "object", nullable: true } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + }; + + #swagger.responses[404] = { + description: "해당 미션이 존재하지 않거나 이미 완료됨", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "UM004" }, + reason: { + type: "string", + example: "해당 미션이 존재하지 않거나 이미 완료되었습니다." + }, + data: { type: "object", nullable: true } + } + }, + success: { type: "object", example: null, nullable: true } + } + } + } + } + }; +*/ + + try { + const { userId, userMissionId } = req.params; + + // 입력 검증 + if (!userId || isNaN(userId)) { + return res.status(400).error({ message: "유효하지 않은 사용자 ID입니다." }); + } + if (!userMissionId || isNaN(userMissionId)) { + return res.status(400).error({ message: "유효하지 않은 미션 ID입니다." }); + } + + const updatedMission = await completeUserMission( + Number(userId), + Number(userMissionId) + ); + + res.status(StatusCodes.OK).success({ + message: "미션 완료로 상태 변경 성공", + data: updatedMission, + }); + } catch (error) { + next(error); + } +}; diff --git a/Week10/s0-yeon/Mission/src/db.config.js b/Week10/s0-yeon/Mission/src/db.config.js new file mode 100644 index 0000000..0621663 --- /dev/null +++ b/Week10/s0-yeon/Mission/src/db.config.js @@ -0,0 +1,20 @@ +import mysql from "mysql2/promise"; +import dotenv from "dotenv"; +import { PrismaClient } from "@prisma/client"; +dotenv.config(); + +export const prisma = new PrismaClient({ log: ["query"] }); + +export const pool = mysql.createPool({ + host: process.env.DB_HOST || "localhost", // mysql의 hostname + user: process.env.DB_USER || "root", // user 이름 + port: process.env.DB_PORT || 3306, // 포트 번호 + database: process.env.DB_NAME || "umc_9th", // 데이터베이스 이름 + password: process.env.DB_PASSWORD || "password", // 비밀번호 + waitForConnections: true, + // Pool에 획득할 수 있는 connection이 없을 때, + // true면 요청을 queue에 넣고 connection을 사용할 수 있게 되면 요청을 실행하며, false이면 즉시 오류를 내보내고 다시 요청 + connectionLimit: 10, // 몇 개의 커넥션을 가지게끔 할 것인지 + queueLimit: 0, // getConnection에서 오류가 발생하기 전에 Pool에 대기할 요청의 개수 한도 +}); + diff --git a/Week10/s0-yeon/Mission/src/dtos/mission.dto.js b/Week10/s0-yeon/Mission/src/dtos/mission.dto.js new file mode 100644 index 0000000..aa5a633 --- /dev/null +++ b/Week10/s0-yeon/Mission/src/dtos/mission.dto.js @@ -0,0 +1,23 @@ +// ✅ 요청 DTO 변환 +export const requestToMission = (body, storeId) => { + return { + storeId: Number(storeId), // ✅ 숫자 변환 확실히! + region: body.region, + missionContent: body.missionContent, + givePoint: body.givePoint, + price: body.price, + }; +}; + +// ✅ 응답 DTO 변환 +export const responseFromMission = (mission) => { + return { + missionId: mission.missionId, + storeId: mission.storeId, + region: mission.region, + missionContent: mission.missionContent, + givePoint: mission.givePoint, + price: mission.price, + createdAt: mission.createdAt, + }; +}; diff --git a/Week10/s0-yeon/Mission/src/dtos/review.dto.js b/Week10/s0-yeon/Mission/src/dtos/review.dto.js new file mode 100644 index 0000000..f9ba4b8 --- /dev/null +++ b/Week10/s0-yeon/Mission/src/dtos/review.dto.js @@ -0,0 +1,19 @@ +// 요청 DTO +export const requestToReview = (body, storeId) => ({ + userId: body.userId, + storeId: parseInt(storeId, 10), + title: body.title, + content: body.content, + star: body.star, +}); + +// 응답 DTO +export const responseFromReview = (review) => ({ + reviewId: review.reviewId, + userId: review.userId, + storeId: review.storeId, + title: review.title, + content: review.content, + star: review.star, + createdAt: review.createdAt, +}); diff --git a/Week10/s0-yeon/Mission/src/dtos/store.dto.js b/Week10/s0-yeon/Mission/src/dtos/store.dto.js new file mode 100644 index 0000000..e12eabd --- /dev/null +++ b/Week10/s0-yeon/Mission/src/dtos/store.dto.js @@ -0,0 +1,26 @@ +// 요청 DTO: body → 내부 데이터 형태 +export const requestToStore = (body) => ({ + name: body.name, + address: body.address, + region: body.region, +}); + +// 응답 DTO: DB 결과 → 클라이언트 반환 형태 +export const responseFromStore = (store) => ({ + storeId: store.storeId, + name: store.name, + address: store.address, + region: store.region, + review: store.review, + totalStar: store.totalStar, + createdAt: store.createdAt, +}); + +export const responseFromReviews = (reviews) => { + return { + data: reviews, + pagination: { + cursor: reviews.length ? reviews[reviews.length - 1].reviewId : null, + }, + }; +}; diff --git a/Week10/s0-yeon/Mission/src/dtos/user.dto.js b/Week10/s0-yeon/Mission/src/dtos/user.dto.js new file mode 100644 index 0000000..fa550cb --- /dev/null +++ b/Week10/s0-yeon/Mission/src/dtos/user.dto.js @@ -0,0 +1,28 @@ +export const bodyToUser = (body) => { + const birth = new Date(body.birth); //날짜 변환 + + return { + email: body.email, //필수 + name: body.name, // 필수 + gender: body.gender, // 필수 + birth, // 필수 + address: body.address || "", //선택 + detailAddress: body.detailAddress || "", //선택 + phoneNumber: body.phoneNumber,//필수 + preferences: body.preferences,// 필수 + password: body.password, // 추가 + }; +}; + + +export const responseFromUser = ({ user, preferences }) => { + const preferFoods = preferences.map( + (preference) => preference.foodCategory.name + ); + + return { + email: user.email, + name: user.name, + preferCategory: preferFoods, + }; +}; \ No newline at end of file diff --git a/Week10/s0-yeon/Mission/src/dtos/userMission.dto.js b/Week10/s0-yeon/Mission/src/dtos/userMission.dto.js new file mode 100644 index 0000000..e7c8f85 --- /dev/null +++ b/Week10/s0-yeon/Mission/src/dtos/userMission.dto.js @@ -0,0 +1,20 @@ +// 요청 DTO +export const requestToUserMission = (body, missionId) => ({ + userId: body.userId, + missionId: parseInt(missionId, 10), + storeId: body.storeId, + timeLimit: body.timeLimit, +}); + +// 응답 DTO +export const responseFromUserMission = (userMission) => ({ + userMissionId: userMission.userMissionId, + userId: userMission.userId, + missionId: userMission.missionId, + storeId: userMission.storeId, + status: userMission.status, + acceptAt: userMission.acceptAt, + timeLimit: userMission.timeLimit, + doneAt: userMission.doneAt, + createdAt: userMission.createdAt, +}); diff --git a/Week10/s0-yeon/Mission/src/errors/customError.js b/Week10/s0-yeon/Mission/src/errors/customError.js new file mode 100644 index 0000000..b458e1b --- /dev/null +++ b/Week10/s0-yeon/Mission/src/errors/customError.js @@ -0,0 +1,59 @@ +import { StatusCodes } from "http-status-codes"; + +export class CustomError extends Error { + constructor(message, statusCode, errorCode = "UNKNOWN_ERROR") { + super(message); + this.statusCode = statusCode; + this.errorCode = errorCode; // ✅ 추가 + } +} + +export class DuplicateUserEmailError extends Error { + errorCode = "U001"; + + constructor(reason, data) { + super(reason); + this.reason = reason; + this.data = data; + } +} + +// 400 Bad Request +export class StoreNotFoundError extends CustomError { + constructor(message = "존재하지 않는 가게입니다.") { + super(message, StatusCodes.BAD_REQUEST, "STORE_NOT_FOUND"); // ✅ 정상 저장됨 + } +} + +export class UserNotFoundError extends CustomError { + constructor(message = "존재하지 않는 유저입니다.") { + super(message, StatusCodes.BAD_REQUEST, "USER_NOT_FOUND"); // ✅ 정상 저장됨 + } +} + +// 400 Bad Request +export class MissionNotFoundError extends CustomError { + constructor(message = "존재하지 않는 미션입니다.") { + super(message, StatusCodes.BAD_REQUEST, "MISSION_NOT_FOUND"); + } +} + +export class UserMissionDuplicateError extends CustomError { + constructor(message = "이미 도전한 미션입니다.") { + super(message, StatusCodes.BAD_REQUEST, "USER_MISSION_DUPLICATE"); + } +} + +// 409 Conflict +export class InvalidMissionStatusError extends CustomError { + constructor(message = "진행 중인 미션만 완료할 수 있습니다.") { + super(message, StatusCodes.CONFLICT, "INVALID_MISSION_STATUS"); + } +} + +// 500 Internal Server Error +export class InternalServerError extends CustomError { + constructor(message = "서버 내부 오류가 발생했습니다.") { + super(message, StatusCodes.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR"); + } +} \ No newline at end of file diff --git a/Week10/s0-yeon/Mission/src/index.js b/Week10/s0-yeon/Mission/src/index.js new file mode 100644 index 0000000..2032ed7 --- /dev/null +++ b/Week10/s0-yeon/Mission/src/index.js @@ -0,0 +1,141 @@ +import cors from "cors"; +import dotenv from "dotenv"; +import express from "express"; +import morgan from "morgan"; +import cookieParser from "cookie-parser"; +import { errorHandler } from "./middlewares/errorHandler.jss"; +import { handleUserSignUp } from "./controllers/user.controller.js"; +import { handleListStoreReviews } from "./controllers/store.controller.js"; +import storeRouter from "./routes/store.route.js"; +import reviewRouter from "./routes/review.route.js"; +import missionRouter from "./routes/mission.route.js"; +import userMissionRouter from "./routes/userMission.route.js"; +import { userRouter } from "./routes/user.route.js"; +import swaggerAutogen from "swagger-autogen"; +import swaggerUiExpress from "swagger-ui-express"; +import passport from "passport"; +import { googleStrategy, jwtStrategy } from "./auth.config.js"; +import { prisma } from "./db.config.js"; +import { isLogin } from "./middleware/auth.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 }); + }; + + res.error = ({ errorCode = "unknown", reason = null, data = null }) => { + return res.json({ + resultType: "FAIL", + error: { errorCode, reason, data }, + success: null, + }); + }; + + next(); +}); + +app.use(cors()); // cors 방식 허용 +app.use(morgan("dev")); +app.use(cookieParser()); +app.use(express.static("public")); // 정적 파일 접근 +app.use(express.json()); // request의 본문을 json으로 해석할 수 있도록 함 (JSON 형태의 요청 body를 파싱하기 위함) +app.use(express.urlencoded({ extended: false })); // 단순 객체 문자열 형태로 본문 데이터 해석 + +app.get("/", (req, res) => { + res.send("Hello World!"); +}); + +//const isLogin = passport.authenticate('jwt', { session: false }); +//app.post("/api/v1/users/signup", handleUserSignUp); +app.get("/api/v1/stores/:storeId/reviews", handleListStoreReviews); + +// API (route 구조로 연결) +app.use("/api/v1/stores", storeRouter); +app.use("/api/v1/stores", reviewRouter); +app.use("/api/v1/stores", missionRouter); +app.use("/api/v1/users", userMissionRouter); +app.use("/api/v1/users", userRouter); + + + +app.get('/mypage', isLogin, (req, res) => { + res.status(200).success({ + message: `인증 성공! ${req.user.name}님의 마이페이지입니다.`, + user: req.user, + }); +}); + +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": "..." } + } + }); + } +); + +app.use( + "/docs", + swaggerUiExpress.serve, + swaggerUiExpress.setup({}, { + swaggerOptions: { + url: "/openapi.json", + }, + }) +); + +app.get("/openapi.json", async (req, res, next) => { + // #swagger.ignore = true + const options = { + openapi: "3.0.0", + disableLogs: true, + writeOutputFile: false, + }; + const outputFile = "/dev/null"; // 파일 출력은 사용하지 않습니다. + const routes = ["./src/index.js"]; + const doc = { + info: { + title: "UMC 9th", + description: "UMC 9th Node.js 테스트 프로젝트입니다.", + }, + host: "localhost:3000", + }; + + const result = await swaggerAutogen(options)(outputFile, routes, doc); + res.json(result ? result.data : null); +}); + + + // 전역 오류를 처리하기 위한 미들웨어 +app.use(errorHandler); // 모든 라우트 뒤에 추가 + + +app.listen(port, () => { + console.log(`Example app listening on port ${port}`); +}); \ No newline at end of file diff --git a/Week10/s0-yeon/Mission/src/middlewares/auth.js b/Week10/s0-yeon/Mission/src/middlewares/auth.js new file mode 100644 index 0000000..8567f16 --- /dev/null +++ b/Week10/s0-yeon/Mission/src/middlewares/auth.js @@ -0,0 +1,4 @@ +// src/middleware/auth.js +import passport from "passport"; + +export const isLogin = passport.authenticate("jwt", { session: false }); diff --git a/Week10/s0-yeon/Mission/src/middlewares/errorHandler.js b/Week10/s0-yeon/Mission/src/middlewares/errorHandler.js new file mode 100644 index 0000000..c6de107 --- /dev/null +++ b/Week10/s0-yeon/Mission/src/middlewares/errorHandler.js @@ -0,0 +1,29 @@ +import { CustomError } from "../errors/customError.js"; + +export const errorHandler = (err, req, res, next) => { + // ✅ CustomError로 정의된 에러인 경우 + if (err instanceof CustomError) { + console.error(`❌ [${err.errorCode}] ${err.message}`); + return res.status(err.statusCode).json({ + resultType: "FAIL", + error: { + errorCode: err.errorCode || "UNKNOWN_ERROR", + reason: err.message, + data: err.data || null, + }, + success: null, + }); + } + + // ✅ 예상치 못한 에러 (CustomError가 아님) + console.error("Unexpected Error:", err); + return res.status(500).json({ + resultType: "FAIL", + error: { + errorCode: "INTERNAL_SERVER_ERROR", + reason: "서버 내부 오류가 발생했습니다.", + data: err.stack || null, + }, + success: null, + }); +}; diff --git a/Week10/s0-yeon/Mission/src/repositories/mission.repository.js b/Week10/s0-yeon/Mission/src/repositories/mission.repository.js new file mode 100644 index 0000000..48dd502 --- /dev/null +++ b/Week10/s0-yeon/Mission/src/repositories/mission.repository.js @@ -0,0 +1,74 @@ + +import { prisma } from "../db.config.js"; +import { + StoreNotFoundError, + InternalServerError, + CustomError, +} from "../errors/customError.js"; +import { findStoreById } from "./store.repository.js"; + + +// 미션 추가 +export const addMissionInDB = async (missionData) => { + const { store_id, region, mission_content, give_point, price } = missionData; + + try { + const store = findStoreById(store_id); + + if (!store) { + throw new StoreNotFoundError("해당 가게가 존재하지 않습니다."); + } + + + const mission = await prisma.mission.create({ + data: { + store_id: Number(store_id), + region, + mission_content, + give_point, + price, + }, + }); +} catch (error) { + throw new InternalServerError("미션 추가중 오류가 발생했습니다."); // 오류를 다시 던져서 호출한 쪽에서 처리할 수 있도록 함 +} + + return mission.mission_id; // 기존 insertId 역할 +}; + +// ✅ 3️⃣ 특정 가게의 미션 목록 조회 +export const getMissionsByStoreId = async (storeId,cursor) => { + try { + if (!storeId || isNaN(storeId)) { + throw new CustomError("유효하지 않은 가게 ID입니다.", 400,'INVALID_STORE_ID'); + } + const store = findStoreById(storeId); + + if (!store) { + throw new StoreNotFoundError("해당 가게가 존재하지 않습니다."); + } + + const mission = await prisma.mission.findMany({ + where: { storeId, + ...(cursor ? {missionId: { lt: Number(cursor) } } : {}), + }, + take: 5, + orderBy: { missionId: "desc" }, + select: { + missionId: true, + region: true, + missionContent: true, + givePoint: true, + price: true, + createdAt: true, + store: { + select: { name: true }, + }, + }, + }); + + return mission.reverse(); +} catch (error) { + throw new InternalServerError("미션 목록 조회중 오류가 발생했습니다."); // 오류를 다시 던져서 호출한 쪽에서 처리할 수 있도록 함 +} +}; \ No newline at end of file diff --git a/Week10/s0-yeon/Mission/src/repositories/review.repository.js b/Week10/s0-yeon/Mission/src/repositories/review.repository.js new file mode 100644 index 0000000..cf73d95 --- /dev/null +++ b/Week10/s0-yeon/Mission/src/repositories/review.repository.js @@ -0,0 +1,30 @@ +import { prisma } from "../db.config.js"; +import { StoreNotFoundError,InternalServerError} from "../errors/customError.js"; +import { findStoreById } from "./store.repository.js"; + + +// 리뷰 추가 +export const addReviewInDB = async (reviewData) => { + const { user_id, store_id, title, content, star } = reviewData; + + try { + const store = await findStoreById(store_id); + + if (!store) { + throw new StoreNotFoundError("해당 가게가 존재하지 않습니다."); + } + const review = await prisma.review.create({ + data: { + user_id: Number(user_id), + store_id: Number(store_id), + title, + content, + star, + }, + }); + + return review.review_id; // 기존 insertId와 동일한 역할 +} catch (error) { + throw new InternalServerError("리뷰 추가중 오류가 발생했습니다."); // 오류를 다시 던져서 호출한 쪽에서 처리할 수 있도록 함 +} +}; diff --git a/Week10/s0-yeon/Mission/src/repositories/store.repository.js b/Week10/s0-yeon/Mission/src/repositories/store.repository.js new file mode 100644 index 0000000..c8f91cc --- /dev/null +++ b/Week10/s0-yeon/Mission/src/repositories/store.repository.js @@ -0,0 +1,65 @@ +import { prisma } from "../db.config.js"; +import { StoreNotFoundError,InternalServerError} from "../errors/customError.js"; + +// 가게 존재 여부 확인 +export const findStoreById = async (storeId) => { + const store = await prisma.store.findUnique({ + where: { storeId: Number(storeId) }, + }); + return store; +}; + +export const addStoreInDB = async (tx,storeData) => { + const { name, address, region } = storeData; + + const store = await tx.store.create({ + data: { + name, + address, + region, + // review, total_star 컬럼이 있다면 0으로 초기화 + review: 0, + totalStar: 0, + }, + }); + + return store.storeId; // 기존 result.insertId 역할 +}; + + +export const getAllStoreReviews = async (storeId, cursor) => { + try { + + const store = findStoreById(storeId); + + if (!store) { + throw new StoreNotFoundError("해당 가게가 존재하지 않습니다."); + } + const reviews = await prisma.review.findMany({ + where: { + storeId: Number(storeId), // 문자열 숫자로 변환 + ...(cursor ? { reviewId: { lt: Number(cursor) } } : {}), // 커서 숫자로 변환 + }, + orderBy: { reviewId: "desc" }, + select: { + reviewId: true, + content: true, + storeId: true, + userId: true, + star: true, + createdAt: true, + user: { + select: { name: true, email: true }, + }, + store: { + select: { name: true, region: true }, + }, + }, + take: 5, + }); + + return reviews.reverse(); // 최신 리뷰가 마지막에 오도록 순서 변경 +} catch (error) { + throw new InternalServerError("리뷰 목록 조회중 오류가 발생했습니다."); // 오류를 다시 던져서 호출한 쪽에서 처리할 수 있도록 함 +} +}; diff --git a/Week10/s0-yeon/Mission/src/repositories/user.repository.js b/Week10/s0-yeon/Mission/src/repositories/user.repository.js new file mode 100644 index 0000000..18ccdfb --- /dev/null +++ b/Week10/s0-yeon/Mission/src/repositories/user.repository.js @@ -0,0 +1,85 @@ +// src/repositories/user.repository.js +import { + DuplicateUserEmailError, + InternalServerError, + CustomError, +} from '../errors/customError.js'; +import { prisma } from "../db.config.js"; + +export const addUser = async (u) => { + try { + const existingUserEmail = await prisma.user.findUnique({ + where: { email: u.email }, + }); + if (existingUserEmail) { + throw new DuplicateUserEmailError('이미 존재하는 이메일입니다.', u); + } + + if (!u.password) { + // u.password로 수정 + throw new CustomError( + '비밀번호가 누락되었습니다. password 필드를 확인하세요.', + 400, + 'PASSWORD_REQUIRED' + ); + } + + const created = await prisma.user.create({ + data: { + email: u.email, + name: u.name, + gender: u.gender, + birth: u.birth, + address: u.address ?? null, + detailAddress: u.detailAddress ?? null, + phoneNumber: u.phoneNumber, + passwordHash: u.password, + }, + select: { userId: true }, + }); + return created.userId; + } catch (error) { + // 커스텀 에러는 그대로 전파 + if (error instanceof CustomError || error.errorCode) { + throw error; + } + console.error(error); + throw new InternalServerError('사용자 추가 중 오류가 발생했습니다.'); + } +}; + +// 사용자 정보 얻기 +export const getUser = async (userId) => { + const user = await prisma.user.findFirstOrThrow({ where: { userId: userId } }); + return user; +}; + +// 음식 선호 카테고리 매핑 +export const setPreference = async (userId, foodCategoryId) => { + await prisma.userFavorCategory.create({ + data: { + userId: userId, + foodCategoryId: foodCategoryId, + }, + }); +}; + +// 사용자 선호 카테고리 반환 +export const getUserPreferencesByUserId = async (userId) => { + try { + const preferences = await prisma.userFavorCategory.findMany({ + select: { //JOIN + userFavorCategoryId: true, + userId: true, + foodCategoryId: true, + foodCategory: true, /// JOIN을 통해 카테고리 상세 정보를 함께 조회 + }, + where: { userId: userId }, //특정한 유저의 선호 데이터만 조회 + orderBy: { foodCategoryId: "asc" }, // 오름차순으로 정렬 + }); + + return preferences; +} catch (error) { + throw internalServerError("사용자 선호음식 카테고리 조회 중 오류가 발생했습니다."); +} +}; \ No newline at end of file diff --git a/Week10/s0-yeon/Mission/src/repositories/userMission.repository.js b/Week10/s0-yeon/Mission/src/repositories/userMission.repository.js new file mode 100644 index 0000000..76424d3 --- /dev/null +++ b/Week10/s0-yeon/Mission/src/repositories/userMission.repository.js @@ -0,0 +1,142 @@ +import { prisma } from "../db.config.js"; // ✅ 이거 하나면 충분 +import {MissionNotFoundError, UserMissionDuplicateError,InvalidMissionStatusError, InternalServerError } from "../errors/customError.js"; + +// ✅ 미션 존재 여부 확인 +export const findMissionById = async (missionId) => { + return await prisma.mission.findUnique({ + where: { missionId: Number(missionId) }, + }); +}; + +// ✅ 중복 도전 여부 확인 +export const findUserMissionDuplicate = async (userId, missionId) => { + const existing = await prisma.userMission.findFirst({ + where: { + userId: Number(userId), + missionId: Number(missionId), + status: "수행중", + }, + }); + + return existing !== null; // true면 이미 도전 중 +}; + +// ✅ 미션 도전 등록 +export const addUserMissionInDB = async (data) => { + const { userId, missionId, storeId, timeLimit } = data; +try { + if (await findMissionById(missionId) === null) { + throw new MissionNotFoundError("해당 미션이 존재하지 않습니다."); + } + + else if (await findUserMissionDuplicate(userId, missionId)) { + throw new UserMissionDuplicateError("이미 도전 중인 미션입니다."); + } + + const userMission = await prisma.userMission.create({ + data: { + userId: Number(userId), + missionId: Number(missionId), + storeId: Number(storeId), + timeLimit: Number(timeLimit), + status: "수행중", + acceptAt: new Date(), + }, + select: { + userMissionId: true, + userId: true, + missionId: true, + storeId: true, + status: true, + acceptAt: true, + timeLimit: true, + doneAt: true, + createdAt: true, + } + }); + + return userMission; // 기존 insertId 역할 +} +catch (error) { + if ( error instanceof MissionNotFoundError || error instanceof UserMissionDuplicateError) { + throw error; // 사용자 에러는 그대로 전달 + } + throw new InternalServerError("미션 도전 등록중 오류가 발생했습니다."); // 오류를 다시 던져서 호출한 쪽에서 처리할 수 있도록 함 +}; +} + +// ✅ 내가 진행 중인 미션 목록 조회 +export const getUserMissionsInProgress = async (userId, cursor) => { + try { + const missions = await prisma.userMission.findMany({ + where: { + userId: Number(userId), + status: "수행중", + ...(cursor ? { userMissionId: { lt: Number(cursor) } } : {}), + }, + orderBy: { userMissionId: "desc" }, + take: 5, + select: { + userMissionId: true, + userId: true, + missionId: true, + status: true, + acceptAt: true, + timeLimit: true, + doneAt: true, + createdAt: true, + mission: { + select: { + missionContent: true, + givePoint: true, + price: true, + store: { + select: { name: true, region: true }, + }, + }, + }, + }, + }); + return missions.reverse(); +} catch (error) { + throw new InternalServerError("진행 중인 미션 목록 조회중 오류가 발생했습니다."); // 오류를 다시 던져서 호출한 쪽에서 처리할 수 있도록 함 +}}; + + +// ✅ 미션 상태 업데이트 +export const updateUserMissionStatus = async (userId, userMissionId, newStatus) => { + // 먼저 현재 상태 확인 +try { + const mission = await prisma.userMission.findFirst({ + where: { + userMissionId: Number(userMissionId), + userId: Number(userId), + }, + }); + + if (!mission) { + throw new MissionNotFoundError("해당 미션이 존재하지 않습니다."); + } + else if( mission.status === newStatus) { + throw new InvalidMissionStatusError("이미 완료된 미션입니다."); + } + + // 상태 업데이트 + const updated = await prisma.userMission.update({ + where: { userMissionId: Number(userMissionId) }, + data: { status: newStatus, ...(newStatus === "완료" ? { doneAt: new Date() } : {})}, // doneAt 하나만 설정하는거라 스프레드 꼭 안써도 되는데 쓰는게 깔끔한 패턴이라나 뭐라나 + }); + + return updated; +} catch (error) { + + if ( + error instanceof MissionNotFoundError || + error instanceof InvalidMissionStatusError + ) { + throw error; + } + + throw new InternalServerError("미션 상태 업데이트 중 오류가 발생했습니다."); // 오류를 다시 던져서 호출한 쪽에서 처리할 수 있도록 함 +} +}; diff --git a/Week10/s0-yeon/Mission/src/routes/mission.route.js b/Week10/s0-yeon/Mission/src/routes/mission.route.js new file mode 100644 index 0000000..9bca4a3 --- /dev/null +++ b/Week10/s0-yeon/Mission/src/routes/mission.route.js @@ -0,0 +1,15 @@ +import express from "express"; +import { handleAddMission,handleListMissionsByStore } from "../controllers/mission.controller.js"; +import { isLogin } from "../index.js"; + +const router = express.Router(); + +// 1-3. 가게에 미션 추가하기 +router.post("/:storeId/missions",isLogin, handleAddMission); + +// ✅ 2. 특정 가게의 미션 목록 조회 (GET) +router.get("/:storeId/missions", handleListMissionsByStore); + + + +export default router; diff --git a/Week10/s0-yeon/Mission/src/routes/review.route.js b/Week10/s0-yeon/Mission/src/routes/review.route.js new file mode 100644 index 0000000..92dd8d3 --- /dev/null +++ b/Week10/s0-yeon/Mission/src/routes/review.route.js @@ -0,0 +1,10 @@ +import express from "express"; +import { handleAddReview } from "../controllers/review.controller.js"; +import { isLogin } from "../index.js"; + +const router = express.Router(); + +// 1-2. 가게에 리뷰 추가하기 +router.post("/:storeId/reviews",isLogin, handleAddReview); + +export default router; diff --git a/Week10/s0-yeon/Mission/src/routes/store.route.js b/Week10/s0-yeon/Mission/src/routes/store.route.js new file mode 100644 index 0000000..f465e94 --- /dev/null +++ b/Week10/s0-yeon/Mission/src/routes/store.route.js @@ -0,0 +1,9 @@ +import express from "express"; +import { handleAddStore } from "../controllers/store.controller.js"; +import { isLogin } from "../index.js"; +const router = express.Router(); + +// 1-1. 특정 지역에 가게 추가하기 +router.post("/",isLogin, handleAddStore); + +export default router; diff --git a/Week10/s0-yeon/Mission/src/routes/user.route.js b/Week10/s0-yeon/Mission/src/routes/user.route.js new file mode 100644 index 0000000..57d10a2 --- /dev/null +++ b/Week10/s0-yeon/Mission/src/routes/user.route.js @@ -0,0 +1,12 @@ +// src/routes/user.route.js +import express from "express"; +import { handleUserSignUp } from "../controllers/user.controller.js"; +import { updateMyInfo } from "../controllers/user.controller.js"; +import { isLogin } from "../middleware/auth.js"; +export const router = express.Router(); + +// ✅ 회원가입 라우트 연결 +router.post("/signup", handleUserSignUp); + +// 내 정보 수정 +router.patch("/me", isLogin, updateMyInfo); \ No newline at end of file diff --git a/Week10/s0-yeon/Mission/src/routes/userMission.route.js b/Week10/s0-yeon/Mission/src/routes/userMission.route.js new file mode 100644 index 0000000..0f2a62d --- /dev/null +++ b/Week10/s0-yeon/Mission/src/routes/userMission.route.js @@ -0,0 +1,15 @@ +import express from "express"; +import { handleChallengeMission,handleListUserMissions,handleCompleteUserMission, } from "../controllers/userMission.controller.js"; +import { isLogin } from "../index.js"; +const router = express.Router(); + +// 1-4. 미션 도전하기 +router.post("/:missionId/challenge",isLogin, handleChallengeMission); + +// ✅ 3. 내가 진행 중인 미션 목록 조회 +router.get("/:userId/missions/in-progress",isLogin, handleListUserMissions); + +// ✅ 5. 진행 중 미션 완료 처리 +router.patch("/:userId/missions/:userMissionId/complete",isLogin, handleCompleteUserMission); + +export default router; diff --git a/Week10/s0-yeon/Mission/src/services/mission.service.js b/Week10/s0-yeon/Mission/src/services/mission.service.js new file mode 100644 index 0000000..a66e246 --- /dev/null +++ b/Week10/s0-yeon/Mission/src/services/mission.service.js @@ -0,0 +1,18 @@ +import { addMissionInDB, getMissionsByStoreId } from "../repositories/mission.repository.js"; + +/** + * ✅ 특정 가게에 미션 추가 + */ +export const addMission = async (missionData) => { + const mission = await addMissionInDB(missionData); + + return mission; +}; + +/** + * ✅ 특정 가게의 미션 목록 조회 + */ +export const listMissionsByStore = async (storeId, cursor) => { + + return await getMissionsByStoreId(storeId, cursor); +}; diff --git a/Week10/s0-yeon/Mission/src/services/review.service.js b/Week10/s0-yeon/Mission/src/services/review.service.js new file mode 100644 index 0000000..e207d88 --- /dev/null +++ b/Week10/s0-yeon/Mission/src/services/review.service.js @@ -0,0 +1,19 @@ +import { addReviewInDB } from "../repositories/review.repository.js"; +import { prisma } from "../db.config.js"; + + +export const addReview = async (reviewData) => { + return await prisma.$transaction(async (tx) => { // $transaction 안에서는 반드시 tx를 써야 같은 트랜잭션 안에서 실행 + const reviewId = await addReviewInDB(reviewData,tx); + + // 등록된 리뷰 다시 조회 + const createdReview = await tx.review.findUnique({ + where: { reviewId: review.reviewId }, + include: { + user: { select: { name: true, email: true } }, + store: { select: { name: true, region: true } }, + }, + }); + return createdReview; + }); +}; diff --git a/Week10/s0-yeon/Mission/src/services/store.service.js b/Week10/s0-yeon/Mission/src/services/store.service.js new file mode 100644 index 0000000..edc9ed0 --- /dev/null +++ b/Week10/s0-yeon/Mission/src/services/store.service.js @@ -0,0 +1,24 @@ +import { addStoreInDB } from "../repositories/store.repository.js"; +import { getAllStoreReviews } from "../repositories/store.repository.js"; +import { responseFromReviews } from "../dtos/store.dto.js"; +import { prisma } from "../db.config.js"; + +export const addStore = async (storeData) => { + return await prisma.$transaction(async (tx) => { // $transaction의 결과를 이 서비스 함수의 결과로 반환 + + // DB에 새 가게 추가 + const storeId = await addStoreInDB(tx, storeData); + + // 삽입된 store 데이터 다시 조회 + const createdStore = await tx.store.findUnique({ + where: { storeId }, + }); + + return createdStore; // 트랜잭션 내부에서 DB 작업 결과를 Prisma에 반환 + }); +}; + +export const listStoreReviews = async (storeId, cursor = 0) => { + const reviews = await getAllStoreReviews(Number(storeId),Number(cursor)); + return responseFromReviews(reviews); +}; \ No newline at end of file diff --git a/Week10/s0-yeon/Mission/src/services/user.service.js b/Week10/s0-yeon/Mission/src/services/user.service.js new file mode 100644 index 0000000..e7d7fb6 --- /dev/null +++ b/Week10/s0-yeon/Mission/src/services/user.service.js @@ -0,0 +1,41 @@ +import bcrypt from "bcryptjs"; // ✅ bcryptjs 사용 (빌드 불필요) +import { responseFromUser } from "../dtos/user.dto.js"; +import { + addUser, + getUser, + getUserPreferencesByUserId, + setPreference, +} from "../repositories/user.repository.js"; + +// 회원가입 로직 +export const userSignUp = async (data) => { + console.log("요청으로 들어온 비밀번호:", data.password); + + + // ✅ 비밀번호 해싱 + const hashedPassword = await bcrypt.hash(data.password, 10); + console.log("해싱된 결과:", hashedPassword); + + // ✅ 사용자 데이터 DB에 추가 + const joinUserId = await addUser({ + email: data.email, + name: data.name, + gender: data.gender, + birth: data.birth, + address: data.address, + phoneNumber: data.phoneNumber, + password: hashedPassword, // ✅ 해시된 비밀번호 저장 + }); + + + // ✅ 선호 카테고리 추가 + for (const preference of data.preferences || []) { + await setPreference(joinUserId, preference); + } + + // ✅ 가입된 사용자 정보 조회 + const user = await getUser(joinUserId); + const preferences = await getUserPreferencesByUserId(joinUserId); + + return responseFromUser({ user, preferences }); +}; diff --git a/Week10/s0-yeon/Mission/src/services/userMission.service.js b/Week10/s0-yeon/Mission/src/services/userMission.service.js new file mode 100644 index 0000000..7ee2f79 --- /dev/null +++ b/Week10/s0-yeon/Mission/src/services/userMission.service.js @@ -0,0 +1,28 @@ +import { + addUserMissionInDB, + getUserMissionsInProgress, + updateUserMissionStatus, +} from "../repositories/userMission.repository.js"; + +// ✅ 1. 미션 도전 (user_mission 등록) +export const challengeMission = async (data) => { + //새 도전 등록 + const userMission = await addUserMissionInDB(data); + // 4️⃣ 반환 + return userMission; + +}; + +// ✅ 2. 내가 진행 중인 미션 목록 조회 +export const listUserMissions = async (userId) => { + return await getUserMissionsInProgress(userId); +}; + +// ✅ 미션 완료 처리 +export const completeUserMission = async (userId, userMissionId) => { + + const updatedMission = await updateUserMissionStatus(userId, userMissionId, "완료"); + + + return updatedMission; +}; \ No newline at end of file