From 5974ce8cb99d927599c120ae692da58f87890aa1 Mon Sep 17 00:00:00 2001 From: chan000518 Date: Sun, 5 Jan 2025 16:46:31 +0900 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20=ED=94=84=EB=A6=AC=EC=A6=88?= =?UTF-8?q?=EB=A7=88,=20=EC=8A=88=ED=8D=BC=20=EC=8A=A4=ED=8A=B8=EB=9F=AD?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프리즈마와 슈퍼 스트럭트 설치 및 에러 처리 추가 --- package.json | 3 ++ prisma/schema.prisma | 15 ++++++ .../.gitkeep => "src/\bstruct/.gitkeep" | 0 src/middlewares/errorHandler.js | 54 +++++++++---------- 4 files changed, 45 insertions(+), 27 deletions(-) create mode 100644 prisma/schema.prisma rename src/models/.gitkeep => "src/\bstruct/.gitkeep" (100%) diff --git a/package.json b/package.json index 08d9154..f698886 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,13 @@ "license": "ISC", "description": "", "dependencies": { + "@prisma/client": "^6.1.0", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.1", "morgan": "^1.10.0", + "prisma": "^6.1.0", + "superstruct": "^2.0.2", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1" }, diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..3162f8f --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,15 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? +// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + diff --git a/src/models/.gitkeep "b/src/\bstruct/.gitkeep" similarity index 100% rename from src/models/.gitkeep rename to "src/\bstruct/.gitkeep" diff --git a/src/middlewares/errorHandler.js b/src/middlewares/errorHandler.js index 20a826f..4bd4f14 100644 --- a/src/middlewares/errorHandler.js +++ b/src/middlewares/errorHandler.js @@ -58,34 +58,34 @@ const errorHandler = (err, req, res, next) => { }); } -// // Prisma의 Known Request Error 처리 -// if (err instanceof Prisma.PrismaClientKnownRequestError) { -// // 에러 코드에 따라 분기 처리 -// switch (err.code) { -// case "P2002": -// // 고유 제약 조건 위반 (중복된 값) -// return res.status(ErrorCodes.Conflict.code).json({ -// message: ErrorCodes.Conflict.message, -// }); -// case "P2025": -// // 레코드를 찾을 수 없음 -// return res.status(ErrorCodes.NotFound.code).json({ -// message: ErrorCodes.NotFound.message, -// }); -// // 다른 에러 코드에 대한 처리 추가 가능 -// default: -// return res.status(ErrorCodes.BadRequest.code).json({ -// message: err.message, -// }); -// } -// } + // Prisma의 Known Request Error 처리 + if (err instanceof Prisma.PrismaClientKnownRequestError) { + // 에러 코드에 따라 분기 처리 + switch (err.code) { + case "P2002": + // 고유 제약 조건 위반 (중복된 값) + return res.status(ErrorCodes.Conflict.code).json({ + message: ErrorCodes.Conflict.message, + }); + case "P2025": + // 레코드를 찾을 수 없음 + return res.status(ErrorCodes.NotFound.code).json({ + message: ErrorCodes.NotFound.message, + }); + // 다른 에러 코드에 대한 처리 추가 가능 + default: + return res.status(ErrorCodes.BadRequest.code).json({ + message: err.message, + }); + } + } -// // Prisma의 Validation Error 처리 -// if (err instanceof Prisma.PrismaClientValidationError) { -// return res.status(ErrorCodes.BadRequest.code).json({ -// message: "데이터 유효성 검증 오류가 발생했습니다", -// }); -// } + // Prisma의 Validation Error 처리 + if (err instanceof Prisma.PrismaClientValidationError) { + return res.status(ErrorCodes.BadRequest.code).json({ + message: "데이터 유효성 검증 오류가 발생했습니다", + }); + } // 예상치 못한 에러의 경우 return res.status(500).json({ From a0bf3433dc4f9c2476350c0e4d2b2f5031f7eeb8 Mon Sep 17 00:00:00 2001 From: chan000518 Date: Sun, 5 Jan 2025 17:04:20 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat:=20=EC=8A=A4=ED=82=A4=EB=A7=88=20?= =?UTF-8?q?=EC=B4=88=EC=95=88=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 유저, 회원, 트레이너, 스켸줄 스키마 생성 Related to: #1 --- .../migration.sql | 85 +++++++++++++++++++ prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 52 ++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 prisma/migrations/20250105075732_add_member_schedules/migration.sql create mode 100644 prisma/migrations/migration_lock.toml diff --git a/prisma/migrations/20250105075732_add_member_schedules/migration.sql b/prisma/migrations/20250105075732_add_member_schedules/migration.sql new file mode 100644 index 0000000..e566ebd --- /dev/null +++ b/prisma/migrations/20250105075732_add_member_schedules/migration.sql @@ -0,0 +1,85 @@ +-- CreateEnum +CREATE TYPE "Role" AS ENUM ('TRAINER', 'MEMBER'); + +-- CreateEnum +CREATE TYPE "Status" AS ENUM ('SCHEDULED', 'COMPLETED', 'CANCELED'); + +-- CreateTable +CREATE TABLE "User" ( + "id" SERIAL NOT NULL, + "email" TEXT NOT NULL, + "password" TEXT NOT NULL, + "name" TEXT NOT NULL, + "role" "Role" NOT NULL DEFAULT 'MEMBER', + "profileImage" TEXT, + "phoneNumber" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Member" ( + "id" SERIAL NOT NULL, + "userId" INTEGER NOT NULL, + + CONSTRAINT "Member_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Trainer" ( + "id" SERIAL NOT NULL, + "userId" INTEGER NOT NULL, + + CONSTRAINT "Trainer_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Schedule" ( + "id" SERIAL NOT NULL, + "date" TIMESTAMP(3) NOT NULL, + "location" TEXT, + "status" "Status" NOT NULL DEFAULT 'SCHEDULED', + "memberId" INTEGER NOT NULL, + "trainerId" INTEGER NOT NULL, + + CONSTRAINT "Schedule_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "_MemberToTrainer" ( + "A" INTEGER NOT NULL, + "B" INTEGER NOT NULL, + + CONSTRAINT "_MemberToTrainer_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Member_userId_key" ON "Member"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Trainer_userId_key" ON "Trainer"("userId"); + +-- CreateIndex +CREATE INDEX "_MemberToTrainer_B_index" ON "_MemberToTrainer"("B"); + +-- AddForeignKey +ALTER TABLE "Member" ADD CONSTRAINT "Member_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Trainer" ADD CONSTRAINT "Trainer_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Schedule" ADD CONSTRAINT "Schedule_memberId_fkey" FOREIGN KEY ("memberId") REFERENCES "Member"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Schedule" ADD CONSTRAINT "Schedule_trainerId_fkey" FOREIGN KEY ("trainerId") REFERENCES "Trainer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_MemberToTrainer" ADD CONSTRAINT "_MemberToTrainer_A_fkey" FOREIGN KEY ("A") REFERENCES "Member"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_MemberToTrainer" ADD CONSTRAINT "_MemberToTrainer_B_fkey" FOREIGN KEY ("B") REFERENCES "Trainer"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..648c57f --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3162f8f..3280b1b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,3 +13,55 @@ datasource db { url = env("DATABASE_URL") } +model User { + id Int @id @default(autoincrement()) + email String @unique + password String + name String + role Role @default(MEMBER) // 역할 구분 + profileImage String? // 프로필 이미지 (선택적) + phoneNumber String? // 연락처 (선택적) + createdAt DateTime @default(now()) + + // 관계 + member Member? + trainer Trainer? +} + +model Member { + id Int @id @default(autoincrement()) + userId Int @unique + user User @relation(fields: [userId], references: [id]) + trainers Trainer[] // 연결된 트레이너 목록 + schedules Schedule[] // 관련된 스케줄 목록 +} + +model Trainer { + id Int @id @default(autoincrement()) + userId Int @unique + user User @relation(fields: [userId], references: [id]) + members Member[] // 관리하는 회원 목록 + schedules Schedule[] // PT 스케줄 +} + +model Schedule { + id Int @id @default(autoincrement()) + date DateTime // PT 날짜 및 시간 + location String? // PT 장소 (선택적) + status Status @default(SCHEDULED) // 일정 상태 + memberId Int + trainerId Int + member Member @relation(fields: [memberId], references: [id]) + trainer Trainer @relation(fields: [trainerId], references: [id]) +} + +enum Role { + TRAINER + MEMBER +} + +enum Status { + SCHEDULED + COMPLETED + CANCELED +} \ No newline at end of file From 6267e2cff0419777fc26062465e647364d94eda1 Mon Sep 17 00:00:00 2001 From: chan000518 Date: Sun, 5 Jan 2025 20:06:36 +0900 Subject: [PATCH 03/11] =?UTF-8?q?feat:=20=EC=9C=A0=EC=A0=80=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기본적인 유저 기능 추가 비밀번호 암호화 로직 추가 유저 스키마에 username 추가 스웨거 변경 asyncHandler 위치 변경(컨트롤러로) Related to: #1 , #2 --- package.json | 2 + .../migration.sql | 12 + prisma/schema.prisma | 1 + "src/\bstruct/.gitkeep" | 0 src/config/swagger.js | 4 +- src/controllers/.gitkeep | 0 .../asyncHandler.js | 0 src/controllers/userController.js | 47 ++++ src/middlewares/JWTauthenticate.js | 33 +++ src/middlewares/errorHandler.js | 2 +- src/routers/index.js | 9 + src/routers/testRouters.js | 2 +- src/routers/userRouters.js | 211 ++++++++++++++++++ src/services/userService.js | 74 ++++++ src/struct/userStruct.js | 30 +++ src/utils/password.js | 16 ++ 16 files changed, 439 insertions(+), 4 deletions(-) create mode 100644 prisma/migrations/20250105104938_add_user_username/migration.sql delete mode 100644 "src/\bstruct/.gitkeep" delete mode 100644 src/controllers/.gitkeep rename src/{middlewares => controllers}/asyncHandler.js (100%) create mode 100644 src/controllers/userController.js create mode 100644 src/middlewares/JWTauthenticate.js create mode 100644 src/routers/userRouters.js create mode 100644 src/services/userService.js create mode 100644 src/struct/userStruct.js create mode 100644 src/utils/password.js diff --git a/package.json b/package.json index f698886..d04fec5 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,11 @@ "description": "", "dependencies": { "@prisma/client": "^6.1.0", + "bcrypt": "^5.1.1", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.1", + "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", "prisma": "^6.1.0", "superstruct": "^2.0.2", diff --git a/prisma/migrations/20250105104938_add_user_username/migration.sql b/prisma/migrations/20250105104938_add_user_username/migration.sql new file mode 100644 index 0000000..8d43419 --- /dev/null +++ b/prisma/migrations/20250105104938_add_user_username/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - A unique constraint covering the columns `[username]` on the table `User` will be added. If there are existing duplicate values, this will fail. + - Added the required column `username` to the `User` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "username" TEXT NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3280b1b..20d59c9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -16,6 +16,7 @@ datasource db { model User { id Int @id @default(autoincrement()) email String @unique + username String @unique // 사용자 고유 아이디 password String name String role Role @default(MEMBER) // 역할 구분 diff --git "a/src/\bstruct/.gitkeep" "b/src/\bstruct/.gitkeep" deleted file mode 100644 index e69de29..0000000 diff --git a/src/config/swagger.js b/src/config/swagger.js index 6d9462f..7f2892f 100644 --- a/src/config/swagger.js +++ b/src/config/swagger.js @@ -5,9 +5,9 @@ const options = { definition: { openapi: '3.0.0', info: { - title: 'Project Setup Template API', + title: 'Project PTLink API', version: '1.0.0', - description: 'API documentation for Project Setup Template', + description: 'API documentation for PTLink', }, servers: [ { diff --git a/src/controllers/.gitkeep b/src/controllers/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/middlewares/asyncHandler.js b/src/controllers/asyncHandler.js similarity index 100% rename from src/middlewares/asyncHandler.js rename to src/controllers/asyncHandler.js diff --git a/src/controllers/userController.js b/src/controllers/userController.js new file mode 100644 index 0000000..fec2562 --- /dev/null +++ b/src/controllers/userController.js @@ -0,0 +1,47 @@ +const { + createUser, + loginUser, + getUserById, + updateUser, + deleteUser,} = require('../services/userService'); + +const asyncHandler = require('./asyncHandler') + +// 사용자 생성 +const createUserController = asyncHandler( async (req, res, next) => { + const user = await createUser(req.body); + res.status(201).json(user); +}); + +// 사용자 로그인 +const loginUserController = asyncHandler( async (req, res, next) => { + const { username, password } = req.body; + const { token, user } = await loginUser(username, password); + res.status(200).json({ token, user }); +}); + +// 사용자 조회 +const getUserByIdController = asyncHandler( async (req, res, next) => { + const user = await getUserById(req.params.id); + res.status(200).json(user); +}); + +// 사용자 수정 +const updateUserController = asyncHandler( async (req, res, next) => { + const user = await updateUser(req.params.id, req.body); + res.status(200).json(user); +}); + +// 사용자 삭제 +const deleteUserController = asyncHandler( async (req, res, next) => { + await deleteUser(req.params.id); + res.status(204).json({message: "삭제 성공"}); +}); + +module.exports = { + createUserController, + loginUserController, + getUserByIdController, + updateUserController, + deleteUserController, +}; \ No newline at end of file diff --git a/src/middlewares/JWTauthenticate.js b/src/middlewares/JWTauthenticate.js new file mode 100644 index 0000000..2a54511 --- /dev/null +++ b/src/middlewares/JWTauthenticate.js @@ -0,0 +1,33 @@ +const jwt = require("jsonwebtoken"); +const { ErrorCodes, CustomError } = require("./errorHandler") + +SECRET_KEY = env.SECRET_KEY + +// JWT 검증 미들웨어 +const authenticateToken = async (req, res, next) => { + let token; + + const authHeader = req.headers["authorization"]; + if (authHeader) { + token = authHeader.split(" ")[1]; + } else if (req.query.token) { + token = req.query.token; + } + + if (!token) + throw new CustomError( ErrorCodes.Forbidden ,'Unauthorized: 토큰이 없습니다.'); // 토큰 없음 + + const decoded = jwt.verify(token, SECRET_KEY); + console.log("Decoded Token:", decoded); + const user = await User.findById(decoded.id); + + if (!user) { + throw new CustomError( ErrorCodes.NotFound , "Unauthorized: 사용자가 없습니다."); + } + + req.user = user; // 이 이후에는 req.user가 데이터베이스의 user + + next(); +}; + +module.exports = authenticateToken; \ No newline at end of file diff --git a/src/middlewares/errorHandler.js b/src/middlewares/errorHandler.js index 4bd4f14..5fa1444 100644 --- a/src/middlewares/errorHandler.js +++ b/src/middlewares/errorHandler.js @@ -1,4 +1,4 @@ -// const { Prisma } = require("@prisma/client"); +const { Prisma } = require("@prisma/client"); // 에러 코드 및 기본 메세지 // 필요에 따라 추가 및 수정 diff --git a/src/routers/index.js b/src/routers/index.js index 8bb76f3..11fae93 100644 --- a/src/routers/index.js +++ b/src/routers/index.js @@ -1,6 +1,7 @@ const express = require("express"); const router = express.Router(); const testRouters = require("./testRouters"); +const userRouters = require("./userRouters") /** * @swagger @@ -10,4 +11,12 @@ const testRouters = require("./testRouters"); */ router.use("", testRouters); +/** + * @swagger + * tags: + * name: User + * description: 기본 유저 관련 엔드포인트 + */ +router.use("", userRouters); + module.exports = router; \ No newline at end of file diff --git a/src/routers/testRouters.js b/src/routers/testRouters.js index 39904a9..6d11203 100644 --- a/src/routers/testRouters.js +++ b/src/routers/testRouters.js @@ -1,5 +1,5 @@ const express = require("express"); -const asyncHandler = require("../middlewares/asyncHandler"); +const asyncHandler = require("../controllers/asyncHandler"); const { ErrorCodes, CustomError } = require("../middlewares/errorHandler"); const router = express.Router(); diff --git a/src/routers/userRouters.js b/src/routers/userRouters.js new file mode 100644 index 0000000..1e1e026 --- /dev/null +++ b/src/routers/userRouters.js @@ -0,0 +1,211 @@ +const express = require("express"); +const { + createUserController, + loginUserController, + getUserByIdController, + updateUserController, + deleteUserController, +} = require("../controllers/userController"); + +const router = express.Router(); + +/** + * @swagger + * /api/user: + * post: + * summary: "사용자 생성" + * tags: [User] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/CreateUser" + * responses: + * 201: + * description: "사용자 생성 성공" + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/User" + */ +router.post("/user", createUserController); + +/** + * @swagger + * /api/login: + * post: + * summary: "사용자 로그인" + * tags: [User] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/LoginUser" + * responses: + * 200: + * description: "로그인 성공 및 토큰 반환" + * content: + * application/json: + * schema: + * type: object + * properties: + * token: + * type: string + * description: "JWT 토큰" + * user: + * $ref: "#/components/schemas/User" + */ +router.post("/login", loginUserController); + +/** + * @swagger + * /api/user/{id}: + * get: + * summary: "특정 사용자 조회" + * tags: [User] + * parameters: + * - name: id + * in: path + * required: true + * schema: + * type: integer + * description: "사용자 ID" + * responses: + * 200: + * description: "사용자 정보 반환" + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/User" + */ +router.get("/user/:id", getUserByIdController); + +/** + * @swagger + * /api/user/{id}: + * put: + * summary: "특정 사용자 수정" + * tags: [User] + * parameters: + * - name: id + * in: path + * required: true + * schema: + * type: integer + * description: "사용자 ID" + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/UpdateUser" + * responses: + * 200: + * description: "수정된 사용자 정보 반환" + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/User" + */ +router.put("/user/:id", updateUserController); + +/** + * @swagger + * /api/user/{id}: + * delete: + * summary: "특정 사용자 삭제" + * tags: [User] + * parameters: + * - name: id + * in: path + * required: true + * schema: + * type: integer + * description: "사용자 ID" + * responses: + * 204: + * description: "삭제 성공" + */ +router.delete("/user/:id", deleteUserController); + +module.exports = router; + +/** + * @swagger + * components: + * schemas: + * User: + * type: object + * properties: + * id: + * type: integer + * description: "사용자 ID" + * username: + * type: string + * description: "사용자 고유 이름" + * email: + * type: string + * description: "사용자 이메일" + * name: + * type: string + * description: "사용자 이름" + * role: + * type: string + * enum: [MEMBER, TRAINER] + * description: "사용자 역할" + * createdAt: + * type: string + * format: date-time + * description: "사용자 생성 날짜" + * CreateUser: + * type: object + * properties: + * username: + * type: string + * description: "사용자 고유 이름" + * password: + * type: string + * description: "사용자 비밀번호" + * email: + * type: string + * description: "사용자 이메일" + * name: + * type: string + * description: "사용자 이름" + * role: + * type: string + * enum: [MEMBER, TRAINER] + * description: "사용자 역할" + * required: + * - username + * - password + * - email + * - name + * - role + * LoginUser: + * type: object + * properties: + * username: + * type: string + * description: "사용자 고유 이름" + * password: + * type: string + * description: "사용자 비밀번호" + * required: + * - username + * - password + * UpdateUser: + * type: object + * properties: + * username: + * type: string + * description: "사용자 고유 이름 (선택)" + * email: + * type: string + * description: "사용자 이메일 (선택)" + * name: + * type: string + * description: "사용자 이름 (선택)" + */ \ No newline at end of file diff --git a/src/services/userService.js b/src/services/userService.js new file mode 100644 index 0000000..81572f2 --- /dev/null +++ b/src/services/userService.js @@ -0,0 +1,74 @@ +const { PrismaClient } = require('@prisma/client'); +const { assert } = require('superstruct'); +const { createUserStruct,updateUserStruct } = require('../struct/userStruct'); +const { hashPassword, comparePassword } = require("../utils/password"); +const { ErrorCodes, CustomError } = require("../middlewares/errorHandler"); +const jwt = require('jsonwebtoken'); + +const prisma = new PrismaClient(); + +// 사용자 생성 +const createUser = async (data) => { + assert(data, createUserStruct); + + // 비밀번호 암호화 + const hashedPassword = await hashPassword(data.password); + + data.password = hashedPassword + + return await prisma.user.create({ + data + }); +}; + +// 사용자 로그인 +const loginUser = async (username, password) => { + const user = await prisma.user.findUnique({ + where: { username }, + }); + + if (!user) { + throw new CustomError(ErrorCodes.BadRequest,'존재하지 않는 아이디입니다.'); + } + + const isPasswordValid = await comparePassword(password, user.password); + + if (!isPasswordValid) { + throw new CustomError(ErrorCodes.BadRequest,'비밀번호가 틀렸습니다.'); + } + + // JWT 생성 + const token = jwt.sign( + { id: user.id, role: user.role }, + process.env.JWT_SECRET, + { expiresIn: '1h' } + ); + + return { token, user }; +}; + +// 사용자 조회 +const getUserById = async (id) => { + return await prisma.user.findUnique({ + where: { id: parseInt(id) }, + }); +}; + +// 사용자 수정 +const updateUser = async (id, data) => { + assert(data, updateUserStruct); + + return await prisma.user.update({ + where: { id: parseInt(id) }, + data, + }); +}; + +// 사용자 삭제 +const deleteUser = async (id) => { + return await prisma.user.delete({ + where: { id: parseInt(id) }, + }); +}; + +module.exports = { createUser, loginUser, getUserById, updateUser, deleteUser }; \ No newline at end of file diff --git a/src/struct/userStruct.js b/src/struct/userStruct.js new file mode 100644 index 0000000..81afff1 --- /dev/null +++ b/src/struct/userStruct.js @@ -0,0 +1,30 @@ +const { object, string, enums, refine, size, partial } = require('superstruct'); + +// 이메일 형식 검증 +const Email = refine(string(), 'Email', (value) => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(value) || 'Invalid email format. Example: user@example.com'; +}); + +// 비밀번호 규칙: 8자 이상, 하나 이상의 영어와 숫자 포함 +const Password = refine(string(), 'Password', (value) => { + const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/; + return ( + passwordRegex.test(value) || + 'Password must be at least 8 characters long, include at least one letter and one number.' + ); +}); + +// User 데이터 검증 스키마 +const UserSchema = object({ + username: size(string(), 3, 20), // 사용자 이름: 3~20자 + email: Email, // 이메일 형식 검증 + password: Password, // 비밀번호 규칙 검증 + name: size(string(), 1, 50), // 이름은 최소 1자, 최대 50자 + role: enums(['TRAINER', 'MEMBER']), // 역할: TRAINER 또는 MEMBER +}); + +createUserStruct = UserSchema; +updateUserStruct = partial(UserSchema); + +module.exports = { createUserStruct,updateUserStruct }; \ No newline at end of file diff --git a/src/utils/password.js b/src/utils/password.js new file mode 100644 index 0000000..25de324 --- /dev/null +++ b/src/utils/password.js @@ -0,0 +1,16 @@ +const bcrypt = require("bcrypt"); + +// 비밀번호 해싱 함수 +// 해싱 작업은 CPU 집약적 작업, 비동기로 처리해야 함 +async function hashPassword(password) { + const salt = await bcrypt.genSalt(10); + return await bcrypt.hash(password, salt); +} + +// 비밀번호 비교 함수 +// 처음이 plain +async function comparePassword(plainPassword, hashedPassword) { + return bcrypt.compare(plainPassword, hashedPassword); +} + +module.exports = { hashPassword, comparePassword }; \ No newline at end of file From 66bfb2c4292e1cede72a4e447d8a0fa0453ab1ef Mon Sep 17 00:00:00 2001 From: chan000518 Date: Sun, 5 Jan 2025 22:30:42 +0900 Subject: [PATCH 04/11] =?UTF-8?q?fix:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EB=B0=98=ED=99=98=20=ED=98=95=EC=8B=9D=20=EC=88=98=EC=A0=95,?= =?UTF-8?q?=20=EC=97=90=EB=9F=AC=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자의 반환 형식에서 해시된 비밀번호 제거 에러 처리 추가 및 구체화 asyncHandler 위치 변경 --- src/controllers/userController.js | 2 +- src/middlewares/JWTauthenticate.js | 13 +-- src/middlewares/errorHandler.js | 93 ++++++++++++---------- src/routers/testRouters.js | 2 +- src/services/userService.js | 24 ++++-- src/struct/userStruct.js | 4 +- src/{controllers => utils}/asyncHandler.js | 0 src/utils/password.js | 8 +- 8 files changed, 88 insertions(+), 58 deletions(-) rename src/{controllers => utils}/asyncHandler.js (100%) diff --git a/src/controllers/userController.js b/src/controllers/userController.js index fec2562..b1da530 100644 --- a/src/controllers/userController.js +++ b/src/controllers/userController.js @@ -5,7 +5,7 @@ const { updateUser, deleteUser,} = require('../services/userService'); -const asyncHandler = require('./asyncHandler') +const asyncHandler = require('../utils/asyncHandler') // 사용자 생성 const createUserController = asyncHandler( async (req, res, next) => { diff --git a/src/middlewares/JWTauthenticate.js b/src/middlewares/JWTauthenticate.js index 2a54511..3f7e0ca 100644 --- a/src/middlewares/JWTauthenticate.js +++ b/src/middlewares/JWTauthenticate.js @@ -1,10 +1,12 @@ const jwt = require("jsonwebtoken"); const { ErrorCodes, CustomError } = require("./errorHandler") +const asyncHandler = require("../utils/asyncHandler") +const { getUserById } = require("../services/userService") SECRET_KEY = env.SECRET_KEY // JWT 검증 미들웨어 -const authenticateToken = async (req, res, next) => { +const authenticateToken = asyncHandler(async (req, res, next) => { let token; const authHeader = req.headers["authorization"]; @@ -14,12 +16,13 @@ const authenticateToken = async (req, res, next) => { token = req.query.token; } - if (!token) + if (!token){ throw new CustomError( ErrorCodes.Forbidden ,'Unauthorized: 토큰이 없습니다.'); // 토큰 없음 - + } + const decoded = jwt.verify(token, SECRET_KEY); console.log("Decoded Token:", decoded); - const user = await User.findById(decoded.id); + const user = await getUserById(decoded.id); if (!user) { throw new CustomError( ErrorCodes.NotFound , "Unauthorized: 사용자가 없습니다."); @@ -28,6 +31,6 @@ const authenticateToken = async (req, res, next) => { req.user = user; // 이 이후에는 req.user가 데이터베이스의 user next(); -}; +}); module.exports = authenticateToken; \ No newline at end of file diff --git a/src/middlewares/errorHandler.js b/src/middlewares/errorHandler.js index 5fa1444..985d286 100644 --- a/src/middlewares/errorHandler.js +++ b/src/middlewares/errorHandler.js @@ -25,7 +25,7 @@ const ErrorCodes = { }, Conflict: { code: 409, - message: "충돌이 발생했습니다", + message: "충돌이 발생했습니다.", }, }; @@ -44,53 +44,64 @@ class CustomError extends Error { // 필요에 따라 에러 처리 추가 // 커스텀 에러가 아닌 에러에 대한 추가 필요! const errorHandler = (err, req, res, next) => { - console.error(err.message); + try{ + console.error(err); - if (err instanceof CustomError) { - return res.status(err.code).json({ - message: err.message, - }); - } + if (err instanceof CustomError) { + return res.status(err.code).json({ + message: err.message, + }); + } - if (err.name == "StructError") { - return res.status(400).json({ - message: err.message, - }); - } + if (err.name == "StructError") { + return res.status(400).json({ + message: err.message.split('--')[1].trim() + }); + } - // Prisma의 Known Request Error 처리 - if (err instanceof Prisma.PrismaClientKnownRequestError) { - // 에러 코드에 따라 분기 처리 - switch (err.code) { - case "P2002": - // 고유 제약 조건 위반 (중복된 값) - return res.status(ErrorCodes.Conflict.code).json({ - message: ErrorCodes.Conflict.message, - }); - case "P2025": - // 레코드를 찾을 수 없음 - return res.status(ErrorCodes.NotFound.code).json({ - message: ErrorCodes.NotFound.message, - }); - // 다른 에러 코드에 대한 처리 추가 가능 - default: - return res.status(ErrorCodes.BadRequest.code).json({ - message: err.message, - }); + // Prisma의 Known Request Error 처리 + if (err instanceof Prisma.PrismaClientKnownRequestError) { + // 에러 코드에 따라 분기 처리 + switch (err.code) { + case "P2002": + // 고유 제약 조건 위반 (중복된 값) + return res.status(ErrorCodes.Conflict.code).json({ + message: ErrorCodes.Conflict.message, + errormeta: err.meta || null, // err.meta가 존재하면 반환, 없으면 null + }); + case "P2025": + // 레코드를 찾을 수 없음 + return res.status(ErrorCodes.NotFound.code).json({ + message: ErrorCodes.NotFound.message, + errormeta: err.meta || null, // err.meta가 존재하면 반환, 없으면 null + }); + // 다른 에러 코드에 대한 처리 추가 가능 + default: + return res.status(ErrorCodes.BadRequest.code).json({ + message: err.message, + errormeta: err.meta || null, // err.meta가 존재하면 반환, 없으면 null + }); + } + } + + // Prisma의 Validation Error 처리 + if (err instanceof Prisma.PrismaClientValidationError) { + return res.status(ErrorCodes.BadRequest.code).json({ + message: "데이터 유효성 검증 오류가 발생했습니다", + }); } - } - // Prisma의 Validation Error 처리 - if (err instanceof Prisma.PrismaClientValidationError) { - return res.status(ErrorCodes.BadRequest.code).json({ - message: "데이터 유효성 검증 오류가 발생했습니다", + // 예상치 못한 에러의 경우 + return res.status(500).json({ + message: "예상치 못한 오류가 발생했습니다", + }); + } + catch { + // 에러 처리중 예상치 못한 에러의 경우 + return res.status(500).json({ + message: "에러 처리 중 예상치 못한 오류가 발생했습니다", }); } - - // 예상치 못한 에러의 경우 - return res.status(500).json({ - message: "예상치 못한 오류가 발생했습니다", - }); }; // 사용 예시 diff --git a/src/routers/testRouters.js b/src/routers/testRouters.js index 6d11203..4675c52 100644 --- a/src/routers/testRouters.js +++ b/src/routers/testRouters.js @@ -1,5 +1,5 @@ const express = require("express"); -const asyncHandler = require("../controllers/asyncHandler"); +const asyncHandler = require("../utils/asyncHandler"); const { ErrorCodes, CustomError } = require("../middlewares/errorHandler"); const router = express.Router(); diff --git a/src/services/userService.js b/src/services/userService.js index 81572f2..e57a4a5 100644 --- a/src/services/userService.js +++ b/src/services/userService.js @@ -1,7 +1,7 @@ const { PrismaClient } = require('@prisma/client'); const { assert } = require('superstruct'); const { createUserStruct,updateUserStruct } = require('../struct/userStruct'); -const { hashPassword, comparePassword } = require("../utils/password"); +const { hashPassword, comparePassword , removePasswordField } = require("../utils/password"); const { ErrorCodes, CustomError } = require("../middlewares/errorHandler"); const jwt = require('jsonwebtoken'); @@ -16,9 +16,11 @@ const createUser = async (data) => { data.password = hashedPassword - return await prisma.user.create({ + const user = await prisma.user.create({ data - }); + }) + + return removePasswordField(user); }; // 사용자 로그인 @@ -44,31 +46,39 @@ const loginUser = async (username, password) => { { expiresIn: '1h' } ); - return { token, user }; + return { token, user: removePasswordField(user) }; }; // 사용자 조회 const getUserById = async (id) => { - return await prisma.user.findUnique({ + const user = await prisma.user.findUnique({ where: { id: parseInt(id) }, }); + + return removePasswordField(user); }; // 사용자 수정 const updateUser = async (id, data) => { assert(data, updateUserStruct); - return await prisma.user.update({ + const user = await prisma.user.update({ where: { id: parseInt(id) }, data, }); + + return removePasswordField(user); }; // 사용자 삭제 const deleteUser = async (id) => { - return await prisma.user.delete({ + const user = await prisma.user.delete({ where: { id: parseInt(id) }, }); + + return removePasswordField(user); }; +// 사용자의 정보 + module.exports = { createUser, loginUser, getUserById, updateUser, deleteUser }; \ No newline at end of file diff --git a/src/struct/userStruct.js b/src/struct/userStruct.js index 81afff1..f57922c 100644 --- a/src/struct/userStruct.js +++ b/src/struct/userStruct.js @@ -3,7 +3,7 @@ const { object, string, enums, refine, size, partial } = require('superstruct'); // 이메일 형식 검증 const Email = refine(string(), 'Email', (value) => { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - return emailRegex.test(value) || 'Invalid email format. Example: user@example.com'; + return emailRegex.test(value) || '잘못된 이메일 형식. Example: user@example.com'; }); // 비밀번호 규칙: 8자 이상, 하나 이상의 영어와 숫자 포함 @@ -11,7 +11,7 @@ const Password = refine(string(), 'Password', (value) => { const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/; return ( passwordRegex.test(value) || - 'Password must be at least 8 characters long, include at least one letter and one number.' + '비밀번호는 최소 8자 이상이어야 하며, 적어도 하나의 문자와 하나의 숫자를 포함해야 합니다.' ); }); diff --git a/src/controllers/asyncHandler.js b/src/utils/asyncHandler.js similarity index 100% rename from src/controllers/asyncHandler.js rename to src/utils/asyncHandler.js diff --git a/src/utils/password.js b/src/utils/password.js index 25de324..3914b23 100644 --- a/src/utils/password.js +++ b/src/utils/password.js @@ -13,4 +13,10 @@ async function comparePassword(plainPassword, hashedPassword) { return bcrypt.compare(plainPassword, hashedPassword); } -module.exports = { hashPassword, comparePassword }; \ No newline at end of file +// 비밀번호 필드 제거 유틸리티 함수 +async function removePasswordField(user) { + const { password, ...safeUser } = user; + return safeUser; +}; + +module.exports = { hashPassword, comparePassword , removePasswordField}; \ No newline at end of file From f754960ac227064c1e249031b54907d113f3f2b5 Mon Sep 17 00:00:00 2001 From: chan000518 Date: Mon, 6 Jan 2025 15:21:52 +0900 Subject: [PATCH 05/11] =?UTF-8?q?fit:=20jwt=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20=EC=97=90=EB=9F=AC=20=EC=B2=98=EB=A6=AC=20=ED=9A=8D?= =?UTF-8?q?=EC=9D=BC=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jwt 환경변수 수정 에러 처리 추가 프론트에서 토큰 관련 로직 처리시 용이하게 에러 코드 획일화 Related to: #3 --- src/middlewares/JWTauthenticate.js | 45 +++++++++++++++++++----------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/src/middlewares/JWTauthenticate.js b/src/middlewares/JWTauthenticate.js index 3f7e0ca..76017b3 100644 --- a/src/middlewares/JWTauthenticate.js +++ b/src/middlewares/JWTauthenticate.js @@ -1,14 +1,17 @@ const jwt = require("jsonwebtoken"); -const { ErrorCodes, CustomError } = require("./errorHandler") -const asyncHandler = require("../utils/asyncHandler") -const { getUserById } = require("../services/userService") +const { ErrorCodes, CustomError } = require("./errorHandler"); +const asyncHandler = require("../utils/asyncHandler"); +const { getUserById } = require("../services/userService"); -SECRET_KEY = env.SECRET_KEY +require('dotenv').config(); // 환경 변수 로드 + +const SECRET_KEY = process.env.JWT_SECRET; // 환경 변수에서 SECRET_KEY 로드 // JWT 검증 미들웨어 const authenticateToken = asyncHandler(async (req, res, next) => { let token; + // 헤더 또는 쿼리에서 토큰 가져오기 const authHeader = req.headers["authorization"]; if (authHeader) { token = authHeader.split(" ")[1]; @@ -16,21 +19,31 @@ const authenticateToken = asyncHandler(async (req, res, next) => { token = req.query.token; } - if (!token){ - throw new CustomError( ErrorCodes.Forbidden ,'Unauthorized: 토큰이 없습니다.'); // 토큰 없음 + if (!token) { + throw new CustomError(ErrorCodes.Unauthorized, 'Unauthorized: 토큰이 없습니다.'); // 토큰 없음 } - - const decoded = jwt.verify(token, SECRET_KEY); - console.log("Decoded Token:", decoded); - const user = await getUserById(decoded.id); - if (!user) { - throw new CustomError( ErrorCodes.NotFound , "Unauthorized: 사용자가 없습니다."); - } + try { + const decoded = jwt.verify(token, SECRET_KEY); // 토큰 검증 + console.log("Decoded Token:", decoded); + + const user = await getUserById(decoded.id); - req.user = user; // 이 이후에는 req.user가 데이터베이스의 user - - next(); + if (!user) { + throw new CustomError(ErrorCodes.NotFound, "Unauthorized: 사용자가 없습니다."); + } + + req.user = user; // 검증된 사용자 데이터를 req.user에 저장 + next(); + } catch (error) { + // 토큰 만료 에러 처리 + if (error.name === "TokenExpiredError") { + throw new CustomError(ErrorCodes.Unauthorized, "Unauthorized: 토큰이 만료되었습니다."); + } + + // 기타 토큰 검증 실패 에러 처리 + throw new CustomError(ErrorCodes.Unauthorized, "Unauthorized: 유효하지 않은 토큰입니다."); + } }); module.exports = authenticateToken; \ No newline at end of file From f0caa918b5e3025891bf2af69a68ebeeeae32b49 Mon Sep 17 00:00:00 2001 From: chan000518 Date: Mon, 6 Jan 2025 15:23:23 +0900 Subject: [PATCH 06/11] =?UTF-8?q?fix:=20=EC=9C=A0=EC=A0=80=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 유저 생성시 멤버인지 트레이너인지에 따라 해당 테이블 생성 --- package.json | 2 +- src/services/userService.js | 21 ++++++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index d04fec5..9dae1b5 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "@prisma/client": "^6.1.0", "bcrypt": "^5.1.1", "cors": "^2.8.5", - "dotenv": "^16.4.5", + "dotenv": "^16.4.7", "express": "^4.21.1", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", diff --git a/src/services/userService.js b/src/services/userService.js index e57a4a5..1240809 100644 --- a/src/services/userService.js +++ b/src/services/userService.js @@ -20,6 +20,19 @@ const createUser = async (data) => { data }) + if(user.role == 'MEMBER'){ + const member = await prisma.member.create({ + data: { userId: user.id }, + }) + } + + if(user.role == 'TRAINER'){ + const trainer = await prisma.trainer.create({ + data: { userId: user.id }, + }) + } + + return removePasswordField(user); }; @@ -38,11 +51,17 @@ const loginUser = async (username, password) => { if (!isPasswordValid) { throw new CustomError(ErrorCodes.BadRequest,'비밀번호가 틀렸습니다.'); } + // JWT_SECRET 확인 + const secret = process.env.JWT_SECRET; + + if (!secret) { + throw new Error("JWT_SECRET 환경 변수가 설정되지 않았습니다."); + } // JWT 생성 const token = jwt.sign( { id: user.id, role: user.role }, - process.env.JWT_SECRET, + secret, { expiresIn: '1h' } ); From 5c3a5357cc80243e08d6d53f0ea402535bc3e876 Mon Sep 17 00:00:00 2001 From: chan000518 Date: Mon, 6 Jan 2025 22:14:17 +0900 Subject: [PATCH 07/11] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B3=BC=20?= =?UTF-8?q?=ED=8A=B8=EB=A0=88=EC=9D=B4=EB=84=88=20=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=A4=84=20=EA=B4=80=EB=A0=A8=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 회원과 트레이너 스케줄 관련 로직 및 기능 추가 스켸줄 스키마 변경 jwt관련 미들웨어로 유저 식별 role관련 미들웨어로 해당 role인지, 엔티티가 있는지 확인 후 가져옴(없을 시 생성) Related To: #3, #4 --- prisma/schema.prisma | 9 +- src/controllers/memberController.js | 47 +++++ src/controllers/trainerController.js | 47 +++++ src/controllers/userController.js | 9 +- .../{JWTauthenticate.js => jwtMiddlewares.js} | 21 +- src/middlewares/roleMiddlewares.js | 23 +++ src/routers/index.js | 20 +- src/routers/memberRouters.js | 192 ++++++++++++++++++ src/routers/trainerRouters.js | 190 +++++++++++++++++ src/routers/userRouters.js | 38 +--- src/services/memberService.js | 143 +++++++++++++ src/services/trainerService.js | 143 +++++++++++++ 12 files changed, 835 insertions(+), 47 deletions(-) create mode 100644 src/controllers/memberController.js create mode 100644 src/controllers/trainerController.js rename src/middlewares/{JWTauthenticate.js => jwtMiddlewares.js} (81%) create mode 100644 src/middlewares/roleMiddlewares.js create mode 100644 src/routers/memberRouters.js create mode 100644 src/routers/trainerRouters.js create mode 100644 src/services/memberService.js create mode 100644 src/services/trainerService.js diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 20d59c9..8e0b275 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -62,7 +62,10 @@ enum Role { } enum Status { - SCHEDULED - COMPLETED - CANCELED + MEMBER_PROPOSED // 멤버가 제안한 상태 + TRAINER_PROPOSED // 트레이너가 제안한 상태 + REJECTED // 거절 상태 + SCHEDULED // PT가 확정된 상태 + COMPLETED // 완료된 상태 + CANCELED // 취소된 상태 } \ No newline at end of file diff --git a/src/controllers/memberController.js b/src/controllers/memberController.js new file mode 100644 index 0000000..3b1d83b --- /dev/null +++ b/src/controllers/memberController.js @@ -0,0 +1,47 @@ +const { + proposeScheduleByMember, + acceptScheduleByMember, + rejectScheduleByMember, + cancelScheduleByMember, +} = require("../services/memberService"); + +const asyncHandler = require('../utils/asyncHandler'); + +// 스케줄 제안 (멤버) +const proposeScheduleByMemberController = asyncHandler(async (req, res) => { + const { trainerId, date, location } = req.body; + const member = req.role; + const schedule = await proposeScheduleByMember(trainerId, member.id, new Date(date), location); + res.status(201).json(schedule); +}); + +// 스케줄 수락 (멤버) +const acceptScheduleByMemberController = asyncHandler(async (req, res) => { + const member = req.role; + const { scheduleId } = req.params; + const schedule = await acceptScheduleByMember(member.id, parseInt(scheduleId)); + res.status(200).json(schedule); +}); + +// 스케줄 거절 (멤버) +const rejectScheduleByMemberController = asyncHandler(async (req, res) => { + const member = req.role; + const { scheduleId } = req.params; + const schedule = await rejectScheduleByMember(member.id, parseInt(scheduleId)); + res.status(200).json(schedule); +}); + +// 스케줄 취소/삭제 (멤버) +const cancelScheduleByMemberController = asyncHandler(async (req, res) => { + const member = req.role; + const { scheduleId } = req.params; + const result = await cancelScheduleByMember(member.id, parseInt(scheduleId)); + res.status(200).json(result); +}); + +module.exports = { + proposeScheduleByMemberController, + acceptScheduleByMemberController, + rejectScheduleByMemberController, + cancelScheduleByMemberController, +}; \ No newline at end of file diff --git a/src/controllers/trainerController.js b/src/controllers/trainerController.js new file mode 100644 index 0000000..838a4e8 --- /dev/null +++ b/src/controllers/trainerController.js @@ -0,0 +1,47 @@ +const { + proposeScheduleByTrainer, + acceptScheduleByTrainer, + rejectScheduleByTrainer, + cancelScheduleByTrainer, +} = require("../services/trainerService"); + +const asyncHandler = require('../utils/asyncHandler'); + +// 스케줄 제안 (트레이너) +const proposeScheduleByTrainerController = asyncHandler(async (req, res) => { + const { memberId, date, location } = req.body; + const trainer= req.role; + const schedule = await proposeScheduleByTrainer(trainer.id, memberId, new Date(date), location); + res.status(201).json(schedule); +}); + +// 스케줄 수락 (트레이너) +const acceptScheduleByTrainerController = asyncHandler(async (req, res) => { + const trainer= req.role; + const { scheduleId } = req.params; + const schedule = await acceptScheduleByTrainer(trainer.id, parseInt(scheduleId)); + res.status(200).json(schedule); +}); + +// 스케줄 거절 (트레이너) +const rejectScheduleByTrainerController = asyncHandler(async (req, res) => { + const trainer= req.role; + const { scheduleId } = req.params; + const schedule = await rejectScheduleByTrainer(trainer.id, parseInt(scheduleId)); + res.status(200).json(schedule); +}); + +// 스케줄 취소/삭제 (트레이너) +const cancelScheduleByTrainerController = asyncHandler(async (req, res) => { + const trainer= req.role; + const { scheduleId } = req.params; + const result = await cancelScheduleByTrainer(trainer.id, parseInt(scheduleId)); + res.status(200).json(result); +}); + +module.exports = { + proposeScheduleByTrainerController, + acceptScheduleByTrainerController, + rejectScheduleByTrainerController, + cancelScheduleByTrainerController, +}; \ No newline at end of file diff --git a/src/controllers/userController.js b/src/controllers/userController.js index b1da530..600063e 100644 --- a/src/controllers/userController.js +++ b/src/controllers/userController.js @@ -3,7 +3,8 @@ const { loginUser, getUserById, updateUser, - deleteUser,} = require('../services/userService'); + deleteUser, +} = require('../services/userService'); const asyncHandler = require('../utils/asyncHandler') @@ -22,19 +23,19 @@ const loginUserController = asyncHandler( async (req, res, next) => { // 사용자 조회 const getUserByIdController = asyncHandler( async (req, res, next) => { - const user = await getUserById(req.params.id); + const user = await getUserById(req.user.id); res.status(200).json(user); }); // 사용자 수정 const updateUserController = asyncHandler( async (req, res, next) => { - const user = await updateUser(req.params.id, req.body); + const user = await updateUser(req.user.id, req.body); res.status(200).json(user); }); // 사용자 삭제 const deleteUserController = asyncHandler( async (req, res, next) => { - await deleteUser(req.params.id); + await deleteUser(req.user.id); res.status(204).json({message: "삭제 성공"}); }); diff --git a/src/middlewares/JWTauthenticate.js b/src/middlewares/jwtMiddlewares.js similarity index 81% rename from src/middlewares/JWTauthenticate.js rename to src/middlewares/jwtMiddlewares.js index 76017b3..5cdcbc9 100644 --- a/src/middlewares/JWTauthenticate.js +++ b/src/middlewares/jwtMiddlewares.js @@ -26,15 +26,6 @@ const authenticateToken = asyncHandler(async (req, res, next) => { try { const decoded = jwt.verify(token, SECRET_KEY); // 토큰 검증 console.log("Decoded Token:", decoded); - - const user = await getUserById(decoded.id); - - if (!user) { - throw new CustomError(ErrorCodes.NotFound, "Unauthorized: 사용자가 없습니다."); - } - - req.user = user; // 검증된 사용자 데이터를 req.user에 저장 - next(); } catch (error) { // 토큰 만료 에러 처리 if (error.name === "TokenExpiredError") { @@ -44,6 +35,16 @@ const authenticateToken = asyncHandler(async (req, res, next) => { // 기타 토큰 검증 실패 에러 처리 throw new CustomError(ErrorCodes.Unauthorized, "Unauthorized: 유효하지 않은 토큰입니다."); } + + const user = await getUserById(decoded.id); + + if (!user) { + throw new CustomError(ErrorCodes.NotFound, "Unauthorized: 사용자가 없습니다."); + } + + req.user = user; // 검증된 사용자 데이터를 req.user에 저장 + next(); + }); -module.exports = authenticateToken; \ No newline at end of file +module.exports = { authenticateToken }; \ No newline at end of file diff --git a/src/middlewares/roleMiddlewares.js b/src/middlewares/roleMiddlewares.js new file mode 100644 index 0000000..3443c95 --- /dev/null +++ b/src/middlewares/roleMiddlewares.js @@ -0,0 +1,23 @@ +const asyncHandler = require("../utils/asyncHandler"); +const { checkOrCreateMember } = require("../services/memberService"); +const { checkOrCreateTrainer } = require("../services/trainerService"); + +// 역할 미들웨어 +const memberMiddleware = asyncHandler(async (req, res, next) => { + // 로그인한 사용자의 역할(Member 또는 Trainer)에 따라 엔터티 조회 + const roleEntity = await checkOrCreateMember(req.user); + + req.role = roleEntity; // 역할 정보를 req.role에 추가 + next(); +}); + +// 역할 미들웨어 +const trainerMiddleware = asyncHandler(async (req, res, next) => { + // 로그인한 사용자의 역할(Member 또는 Trainer)에 따라 엔터티 조회 + const roleEntity = await checkOrCreateTrainer(req.user); + + req.role = roleEntity; // 역할 정보를 req.role에 추가 + next(); +}); + +module.exports = { memberMiddleware , trainerMiddleware}; \ No newline at end of file diff --git a/src/routers/index.js b/src/routers/index.js index 11fae93..31d47f4 100644 --- a/src/routers/index.js +++ b/src/routers/index.js @@ -1,7 +1,9 @@ const express = require("express"); const router = express.Router(); const testRouters = require("./testRouters"); -const userRouters = require("./userRouters") +const userRouters = require("./userRouters"); +const memberRouters = require("./memberRouters"); +const trainerRouters = require("./trainerRouters"); /** * @swagger @@ -19,4 +21,20 @@ router.use("", testRouters); */ router.use("", userRouters); +/** + * @swagger + * tags: + * name: Member + * description: 회원 관련 엔드포인트 + */ +router.use("member/", memberRouters); + +/** + * @swagger + * tags: + * name: Trainer + * description: 트레이너 관련 엔드포인트 + */ +router.use("trainer/", trainerRouters); + module.exports = router; \ No newline at end of file diff --git a/src/routers/memberRouters.js b/src/routers/memberRouters.js new file mode 100644 index 0000000..b5f434a --- /dev/null +++ b/src/routers/memberRouters.js @@ -0,0 +1,192 @@ +const express = require("express"); +const { + proposeScheduleByMemberController, + acceptScheduleByMemberController, + rejectScheduleByMemberController, + cancelScheduleByMemberController, +} = require("../controllers/memberController"); +const { authenticateToken } = require("../middlewares/jwtMiddlewares"); +const { memberMiddleware } = require("../middlewares/roleMiddlewares"); + +const router = express.Router(); + +/** + * @swagger + * /api/member/schedules/propose: + * post: + * summary: "멤버가 트레이너에게 스케줄 제안" + * tags: [Member Schedules] + * security: + * - BearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * trainerId: + * type: integer + * description: "트레이너 ID" + * date: + * type: string + * format: date-time + * description: "스케줄 날짜와 시간" + * location: + * type: string + * description: "스케줄 장소" + * responses: + * 201: + * description: "스케줄 생성 성공" + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Schedule" + * 400: + * description: "잘못된 요청" + * 401: + * description: "인증 실패" + */ +router.post("schedules/propose", authenticateToken, memberMiddleware, proposeScheduleByMemberController ); + +/** + * @swagger + * /api/member/schedules/{scheduleId}/accept: + * put: + * summary: "멤버가 트레이너가 제안한 스케줄 수락" + * tags: [Member Schedules] + * security: + * - BearerAuth: [] + * parameters: + * - name: scheduleId + * in: path + * required: true + * schema: + * type: integer + * description: "스케줄 ID" + * responses: + * 200: + * description: "스케줄 수락 성공" + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Schedule" + * 400: + * description: "잘못된 요청" + * 401: + * description: "인증 실패" + * 403: + * description: "권한 없음" + */ +router.put( "/schedules/:scheduleId/accept", authenticateToken, memberMiddleware, acceptScheduleByMemberController ); + +/** + * @swagger + * /api/member/schedules/{scheduleId}/reject: + * put: + * summary: "멤버가 트레이너가 제안한 스케줄 거절" + * tags: [Member Schedules] + * security: + * - BearerAuth: [] + * parameters: + * - name: scheduleId + * in: path + * required: true + * schema: + * type: integer + * description: "스케줄 ID" + * responses: + * 200: + * description: "스케줄 거절 성공" + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Schedule" + * 400: + * description: "잘못된 요청" + * 401: + * description: "인증 실패" + * 403: + * description: "권한 없음" + */ +router.put( + "/schedules/:scheduleId/reject", authenticateToken, memberMiddleware, rejectScheduleByMemberController +); + +/** + * @swagger + * /api/member/schedules/{scheduleId}/cancel: + * delete: + * summary: "멤버가 스케줄 취소 또는 삭제" + * tags: [Member Schedules] + * security: + * - BearerAuth: [] + * parameters: + * - name: scheduleId + * in: path + * required: true + * schema: + * type: integer + * description: "스케줄 ID" + * responses: + * 200: + * description: "스케줄 취소 또는 삭제 성공" + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Schedule" + * 400: + * description: "잘못된 요청" + * 401: + * description: "인증 실패" + * 403: + * description: "권한 없음" + */ +router.delete( "/schedules/:scheduleId/cancel", authenticateToken, memberMiddleware, cancelScheduleByMemberController ); + +module.exports = router; + +/** + * @swagger + * components: + * schemas: + * Schedule: + * type: object + * properties: + * id: + * type: integer + * description: "스케줄 ID" + * example: 1 + * date: + * type: string + * format: date-time + * description: "스케줄 날짜 및 시간" + * example: "2025-01-01T10:00:00Z" + * location: + * type: string + * description: "스케줄 장소" + * example: "Gym A" + * status: + * type: string + * enum: + * - MEMBER_PROPOSED + * - TRAINER_PROPOSED + * - SCHEDULED + * - REJECTED + * - CANCELED + * description: "스케줄 상태" + * example: "MEMBER_PROPOSED" + * memberId: + * type: integer + * description: "멤버 ID" + * example: 1 + * trainerId: + * type: integer + * description: "트레이너 ID" + * example: 2 + * securitySchemes: + * BearerAuth: + * type: http + * scheme: bearer + * bearerFormat: JWT + */ diff --git a/src/routers/trainerRouters.js b/src/routers/trainerRouters.js new file mode 100644 index 0000000..ce345ea --- /dev/null +++ b/src/routers/trainerRouters.js @@ -0,0 +1,190 @@ +const express = require("express"); +const { + proposeScheduleByTrainerController, + acceptScheduleByTrainerController, + rejectScheduleByTrainerController, + cancelScheduleByTrainerController, +} = require("../controllers/trainerController"); +const { authenticateToken } = require("../middlewares/jwtMiddlewares"); +const { trainerMiddleware } = require("../middlewares/roleMiddlewares"); + +const router = express.Router(); + +/** + * @swagger + * /api/trainer/schedules/propose: + * post: + * summary: "트레이너가 멤버에게 스케줄 제안" + * tags: [Trainer Schedules] + * security: + * - BearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * memberId: + * type: integer + * description: "멤버 ID" + * date: + * type: string + * format: date-time + * description: "스케줄 날짜와 시간" + * location: + * type: string + * description: "스케줄 장소" + * responses: + * 201: + * description: "스케줄 생성 성공" + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Schedule" + * 400: + * description: "잘못된 요청" + * 401: + * description: "인증 실패" + */ +router.post("/schedules/propose", authenticateToken, trainerMiddleware, proposeScheduleByTrainerController); + +/** + * @swagger + * /api/trainer/schedules/{scheduleId}/accept: + * put: + * summary: "트레이너가 멤버가 제안한 스케줄 수락" + * tags: [Trainer Schedules] + * security: + * - BearerAuth: [] + * parameters: + * - name: scheduleId + * in: path + * required: true + * schema: + * type: integer + * description: "스케줄 ID" + * responses: + * 200: + * description: "스케줄 수락 성공" + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Schedule" + * 400: + * description: "잘못된 요청" + * 401: + * description: "인증 실패" + * 403: + * description: "권한 없음" + */ +router.put( "/schedules/:scheduleId/accept", authenticateToken, trainerMiddleware, acceptScheduleByTrainerController); + +/** + * @swagger + * /api/trainer/schedules/{scheduleId}/reject: + * put: + * summary: "트레이너가 멤버가 제안한 스케줄 거절" + * tags: [Trainer Schedules] + * security: + * - BearerAuth: [] + * parameters: + * - name: scheduleId + * in: path + * required: true + * schema: + * type: integer + * description: "스케줄 ID" + * responses: + * 200: + * description: "스케줄 거절 성공" + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Schedule" + * 400: + * description: "잘못된 요청" + * 401: + * description: "인증 실패" + * 403: + * description: "권한 없음" + */ +router.put( "/schedules/:scheduleId/reject", authenticateToken, trainerMiddleware, rejectScheduleByTrainerController ); + +/** + * @swagger + * /api/trainer/schedules/{scheduleId}/cancel: + * delete: + * summary: "트레이너가 스케줄 취소 또는 삭제" + * tags: [Trainer Schedules] + * security: + * - BearerAuth: [] + * parameters: + * - name: scheduleId + * in: path + * required: true + * schema: + * type: integer + * description: "스케줄 ID" + * responses: + * 200: + * description: "스케줄 취소 또는 삭제 성공" + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Schedule" + * 400: + * description: "잘못된 요청" + * 401: + * description: "인증 실패" + * 403: + * description: "권한 없음" + */ +router.delete( "/schedules/:scheduleId/cancel", authenticateToken, trainerMiddleware, cancelScheduleByTrainerController ); + +module.exports = router; + +/** + * @swagger + * components: + * schemas: + * Schedule: + * type: object + * properties: + * id: + * type: integer + * description: "스케줄 ID" + * example: 1 + * date: + * type: string + * format: date-time + * description: "스케줄 날짜 및 시간" + * example: "2025-01-01T10:00:00Z" + * location: + * type: string + * description: "스케줄 장소" + * example: "Gym A" + * status: + * type: string + * enum: + * - MEMBER_PROPOSED + * - TRAINER_PROPOSED + * - SCHEDULED + * - REJECTED + * - CANCELED + * description: "스케줄 상태" + * example: "TRAINER_PROPOSED" + * memberId: + * type: integer + * description: "멤버 ID" + * example: 1 + * trainerId: + * type: integer + * description: "트레이너 ID" + * example: 2 + * securitySchemes: + * BearerAuth: + * type: http + * scheme: bearer + * bearerFormat: JWT + */ \ No newline at end of file diff --git a/src/routers/userRouters.js b/src/routers/userRouters.js index 1e1e026..fcf4cba 100644 --- a/src/routers/userRouters.js +++ b/src/routers/userRouters.js @@ -6,6 +6,7 @@ const { updateUserController, deleteUserController, } = require("../controllers/userController"); +const { authenticateToken } = require("../middlewares/jwtMiddlewares") const router = express.Router(); @@ -61,17 +62,10 @@ router.post("/login", loginUserController); /** * @swagger - * /api/user/{id}: + * /api/user: * get: - * summary: "특정 사용자 조회" + * summary: "사용자 조회" * tags: [User] - * parameters: - * - name: id - * in: path - * required: true - * schema: - * type: integer - * description: "사용자 ID" * responses: * 200: * description: "사용자 정보 반환" @@ -80,21 +74,14 @@ router.post("/login", loginUserController); * schema: * $ref: "#/components/schemas/User" */ -router.get("/user/:id", getUserByIdController); +router.get("/user", authenticateToken , getUserByIdController); /** * @swagger - * /api/user/{id}: + * /api/user: * put: - * summary: "특정 사용자 수정" + * summary: "사용자 수정" * tags: [User] - * parameters: - * - name: id - * in: path - * required: true - * schema: - * type: integer - * description: "사용자 ID" * requestBody: * required: true * content: @@ -109,26 +96,19 @@ router.get("/user/:id", getUserByIdController); * schema: * $ref: "#/components/schemas/User" */ -router.put("/user/:id", updateUserController); +router.put("/user", authenticateToken , updateUserController); /** * @swagger * /api/user/{id}: * delete: - * summary: "특정 사용자 삭제" + * summary: "사용자 삭제" * tags: [User] - * parameters: - * - name: id - * in: path - * required: true - * schema: - * type: integer - * description: "사용자 ID" * responses: * 204: * description: "삭제 성공" */ -router.delete("/user/:id", deleteUserController); +router.delete("/user", authenticateToken , deleteUserController); module.exports = router; diff --git a/src/services/memberService.js b/src/services/memberService.js new file mode 100644 index 0000000..71bc622 --- /dev/null +++ b/src/services/memberService.js @@ -0,0 +1,143 @@ +const { PrismaClient } = require('@prisma/client'); +const { ErrorCodes, CustomError } = require("../middlewares/errorHandler"); + +const prisma = new PrismaClient(); + +const checkOrCreateMember = async (user) => { + if (user.role === 'MEMBER') { + // 회원(Member) 엔트리 확인 + let member = await prisma.member.findUnique({ + where: { userId: user.id }, + }); + + // 존재하지 않으면 생성 + if (!member) { + member = await prisma.member.create({ + data: { userId: user.id }, + }); + } + + return member + } else if (user.role === 'TRAINER') { + // 트레이너(Trainer) 엔트리 확인 + throw new CustomError(ErrorCodes.Forbidden,"잘못된 접근입니다. ") + } +}; + +// 스케줄 상태 열거형 +const ScheduleStatus = { + MEMBER_PROPOSED: "MEMBER_PROPOSED", + TRAINER_PROPOSED: "TRAINER_PROPOSED", + SCHEDULED: "SCHEDULED", + REJECTED: "REJECTED", + CANCELED: "CANCELED", +}; + +// 멤버가 스케줄 제안 +const proposeScheduleByMember = async (memberId, trainerId, date, location) => { + return await prisma.schedule.create({ + data: { + date, + location, + status: ScheduleStatus.MEMBER_PROPOSED, + memberId, + trainerId, + }, + }); +}; + +// 멤버가 스케줄 수락 +const acceptScheduleByMember = async (memberId, scheduleId) => { + const schedule = await prisma.schedule.findUnique({ + where: { id: scheduleId }, + }); + + if (!schedule) { + throw new CustomError(ErrorCodes.NotFound, "해당 Schedule이 없습니다."); + } + + if (schedule.memberId !== memberId) { + throw new CustomError(ErrorCodes.Forbidden, "Schedule의 대상이 아닙니다."); + } + + if (schedule.status === ScheduleStatus.TRAINER_PROPOSED) { + return await prisma.schedule.update({ + where: { id: scheduleId }, + data: { + status: ScheduleStatus.SCHEDULED, + }, + }); + } else { + throw new CustomError(ErrorCodes.BadRequest, "Schedule 수락 대상이 아닙니다."); + } +}; + +// 멤버가 스케줄 거절 +const rejectScheduleByMember = async (memberId, scheduleId) => { + const schedule = await prisma.schedule.findUnique({ + where: { id: scheduleId }, + }); + + if (!schedule) { + throw new CustomError(ErrorCodes.NotFound, "해당 Schedule이 없습니다."); + } + + if (schedule.memberId !== memberId) { + throw new CustomError(ErrorCodes.Forbidden, "Schedule의 대상이 아닙니다."); + } + + if (schedule.status === ScheduleStatus.TRAINER_PROPOSED) { + return await prisma.schedule.update({ + where: { id: scheduleId }, + data: { + status: ScheduleStatus.REJECTED, + }, + }); + } else { + throw new CustomError(ErrorCodes.BadRequest, "Schedule 거절 대상이 아닙니다."); + } +}; + +// 멤버가 스케줄 취소, 삭제 +const cancelSchedule = async (memberId, scheduleId) => { + const schedule = await prisma.schedule.findUnique({ + where: { id: scheduleId }, + }); + + if (!schedule) { + throw new CustomError(ErrorCodes.NotFound, "Schedule not found."); + } + + if (schedule.memberId !== memberId) { + throw new CustomError(ErrorCodes.Forbidden, "Schedule의 대상이 아닙니다."); + } + + // 취소 + if (schedule.status === ScheduleStatus.SCHEDULED) { + return await prisma.schedule.update({ + where: { id: scheduleId }, + data: { + status: ScheduleStatus.CANCELED, + }, + }); + } + // 삭제 + else if ( + schedule.status === ScheduleStatus.MEMBER_PROPOSED || + schedule.status === ScheduleStatus.REJECTED + ) { + return await prisma.schedule.delete({ + where: { id: scheduleId }, + }); + } else { + throw new CustomError(ErrorCodes.BadRequest, "Schedule 취소가 불가능합니다."); + } +}; + +module.exports = { + checkOrCreateMember, + proposeScheduleByMember, + acceptScheduleByMember, + rejectScheduleByMember, + cancelSchedule, +}; \ No newline at end of file diff --git a/src/services/trainerService.js b/src/services/trainerService.js new file mode 100644 index 0000000..d5844eb --- /dev/null +++ b/src/services/trainerService.js @@ -0,0 +1,143 @@ +const { PrismaClient } = require('@prisma/client'); +const { ErrorCodes, CustomError } = require("../middlewares/errorHandler"); + +const prisma = new PrismaClient(); + +const checkOrCreateTrainer = async (user) => { + if (user.role === 'TRAINER') { + // 회원(trainer) 엔트리 확인 + let trainer = await prisma.trainer.findUnique({ + where: { userId: user.id }, + }); + + // 존재하지 않으면 생성 + if (!trainer) { + trainer = await prisma.trainer.create({ + data: { userId: user.id }, + }); + } + + return trainer + } else if (user.role === 'MEMBER') { + // 트레이너(Trainer) 엔트리 확인 + throw new CustomError(ErrorCodes.Forbidden,"잘못된 접근입니다. ") + } +}; + +// 스케줄 상태 열거형 +const ScheduleStatus = { + trainer_PROPOSED: "trainer_PROPOSED", + TRAINER_PROPOSED: "TRAINER_PROPOSED", + SCHEDULED: "SCHEDULED", + REJECTED: "REJECTED", + CANCELED: "CANCELED", +}; + +// 트레이너가 스케줄 제안 +const proposeScheduleByTrainer = async (trainerId, memberId, date, location) => { + return await prisma.schedule.create({ + data: { + date, + location, + status: ScheduleStatus.TRAINER_PROPOSED, + trainerId, + memberId, + }, + }); +}; + +// 트레이너가 스케줄 수락 +const acceptScheduleByTrainer = async (trainerId, scheduleId) => { + const schedule = await prisma.schedule.findUnique({ + where: { id: scheduleId }, + }); + + if (!schedule) { + throw new CustomError(ErrorCodes.NotFound, "해당 Schedule이 없습니다."); + } + + if (schedule.trainerId !== trainerId) { + throw new CustomError(ErrorCodes.Forbidden, "Schedule의 대상이 아닙니다."); + } + + if (schedule.status === ScheduleStatus.trainer_PROPOSED) { + return await prisma.schedule.update({ + where: { id: scheduleId }, + data: { + status: ScheduleStatus.SCHEDULED, + }, + }); + } else { + throw new CustomError(ErrorCodes.BadRequest, "Schedule 수락 대상이 아닙니다."); + } +}; + +// 트레이너가 스케줄 거절 +const rejectScheduleByTrainer = async (trainerId, scheduleId) => { + const schedule = await prisma.schedule.findUnique({ + where: { id: scheduleId }, + }); + + if (!schedule) { + throw new CustomError(ErrorCodes.NotFound, "해당 Schedule이 없습니다."); + } + + if (schedule.trainerId !== trainerId) { + throw new CustomError(ErrorCodes.Forbidden, "Schedule의 대상이 아닙니다."); + } + + if (schedule.status === ScheduleStatus.trainer_PROPOSED) { + return await prisma.schedule.update({ + where: { id: scheduleId }, + data: { + status: ScheduleStatus.REJECTED, + }, + }); + } else { + throw new CustomError(ErrorCodes.BadRequest, "Schedule 거절 대상이 아닙니다."); + } +}; + +// 트레이너가 스케줄 취소, 삭제 +const cancelScheduleByTrainer = async (trainerId, scheduleId) => { + const schedule = await prisma.schedule.findUnique({ + where: { id: scheduleId }, + }); + + if (!schedule) { + throw new CustomError(ErrorCodes.NotFound, "Schedule not found."); + } + + if (schedule.trainerId !== trainerId) { + throw new CustomError(ErrorCodes.Forbidden, "Schedule의 대상이 아닙니다."); + } + + // 취소 + if (schedule.status === ScheduleStatus.SCHEDULED) { + return await prisma.schedule.update({ + where: { id: scheduleId }, + data: { + status: ScheduleStatus.CANCELED, + }, + }); + } + // 삭제 + else if ( + schedule.status === ScheduleStatus.TRAINER_PROPOSED || + schedule.status === ScheduleStatus.REJECTED + ) { + return await prisma.schedule.delete({ + where: { id: scheduleId }, + }); + } else { + throw new CustomError(ErrorCodes.BadRequest, "Schedule 취소가 불가능합니다."); + } +}; + +module.exports = { + checkOrCreateTrainer, + proposeScheduleByTrainer, + acceptScheduleByTrainer, + rejectScheduleByTrainer, + cancelScheduleByTrainer, +}; \ No newline at end of file From f6d6da3d04282ebc34b025626136a3a31ff28237 Mon Sep 17 00:00:00 2001 From: chan000518 Date: Tue, 7 Jan 2025 03:02:56 +0900 Subject: [PATCH 08/11] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=ED=8A=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EB=84=88=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 회원의 정보 조회 트레이너의 회원 관리 기능 추가 Related to: #4 --- .../migration.sql | 11 ++ src/controllers/memberController.js | 29 +++ src/controllers/trainerController.js | 53 ++++++ src/middlewares/jwtMiddlewares.js | 8 +- src/routers/index.js | 4 +- src/routers/memberRouters.js | 70 ++++++- src/routers/trainerRouters.js | 172 +++++++++++++++++- src/routers/userRouters.js | 21 ++- src/services/memberService.js | 44 +++++ src/services/trainerService.js | 129 +++++++++++++ src/services/userService.js | 3 +- src/struct/memberStruct.js | 0 src/utils/asyncHandler.js | 2 +- src/utils/dataFormat.js | 16 ++ src/utils/password.js | 2 +- 15 files changed, 542 insertions(+), 22 deletions(-) create mode 100644 prisma/migrations/20250106143055_add_schedule_status/migration.sql create mode 100644 src/struct/memberStruct.js create mode 100644 src/utils/dataFormat.js diff --git a/prisma/migrations/20250106143055_add_schedule_status/migration.sql b/prisma/migrations/20250106143055_add_schedule_status/migration.sql new file mode 100644 index 0000000..404878e --- /dev/null +++ b/prisma/migrations/20250106143055_add_schedule_status/migration.sql @@ -0,0 +1,11 @@ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "Status" ADD VALUE 'MEMBER_PROPOSED'; +ALTER TYPE "Status" ADD VALUE 'TRAINER_PROPOSED'; +ALTER TYPE "Status" ADD VALUE 'REJECTED'; diff --git a/src/controllers/memberController.js b/src/controllers/memberController.js index 3b1d83b..c1e9727 100644 --- a/src/controllers/memberController.js +++ b/src/controllers/memberController.js @@ -1,4 +1,6 @@ const { + getMemberSchedulesByMonth, + getRelatedTrainers, proposeScheduleByMember, acceptScheduleByMember, rejectScheduleByMember, @@ -7,6 +9,31 @@ const { const asyncHandler = require('../utils/asyncHandler'); +// 멤버가 자신의 스케줄 조회 (특정 한 달) +const getMemberSchedulesByMonthController = asyncHandler(async (req, res) => { + const { month } = req.query; + const member = req.role; + + if (!month) { + throw new CustomError(ErrorCodes.BadRequest, 'month는 필수입니다. 형식: YYYY-MM'); + } + + const monthDate = new Date(`${month}-01`); // YYYY-MM 형식에서 Date 객체 생성 + if (isNaN(monthDate)) { + throw new CustomError(ErrorCodes.BadRequest, '올바른 month 형식이 아닙니다. 예: 2025-01'); + } + + const schedules = await getMemberSchedulesByMonth(member.id, monthDate); + res.status(200).json(schedules); +}); + +// 멤버가 자신과 관련 있는 트레이너 조회 +const getRelatedTrainersController = asyncHandler(async (req, res) => { + const member = req.role; + const trainers = await getRelatedTrainers(member.id); + res.status(200).json(trainers); +}); + // 스케줄 제안 (멤버) const proposeScheduleByMemberController = asyncHandler(async (req, res) => { const { trainerId, date, location } = req.body; @@ -40,6 +67,8 @@ const cancelScheduleByMemberController = asyncHandler(async (req, res) => { }); module.exports = { + getMemberSchedulesByMonthController, + getRelatedTrainersController, proposeScheduleByMemberController, acceptScheduleByMemberController, rejectScheduleByMemberController, diff --git a/src/controllers/trainerController.js b/src/controllers/trainerController.js index 838a4e8..fcd656f 100644 --- a/src/controllers/trainerController.js +++ b/src/controllers/trainerController.js @@ -1,4 +1,8 @@ const { + addMemberToTrainer, + getTrainerMembers, + getMemberByTrainer, + getTrainerSchedulesByMonth, proposeScheduleByTrainer, acceptScheduleByTrainer, rejectScheduleByTrainer, @@ -7,6 +11,51 @@ const { const asyncHandler = require('../utils/asyncHandler'); +// 멤버를 트레이너의 관리 목록에 추가 +const addMemberToTrainerController = asyncHandler(async (req, res) => { + const { memberId } = req.params; // 경로 파라미터에서 memberId 추출 + const trainer = req.role; // 로그인한 트레이너 정보 + + const result = await addMemberToTrainer(trainer.id, parseInt(memberId)); + res.status(201).json(result); +}); + +// 트레이너가 관리하는 모든 회원 리스트 +const getTrainerMembersController = asyncHandler(async (req, res) => { + const { page = 1, limit = 10 } = req.query; + const trainer = req.role; + + const result = await getTrainerMembers(trainer.id, parseInt(page), parseInt(limit)); + res.status(200).json(result); +}); + +// 트레이너의 모든 스케줄 (특정 한 달) +const getTrainerSchedulesByMonthController = asyncHandler(async (req, res) => { + const { month } = req.query; + const trainer = req.role; + + if (!month) { + throw new CustomError(ErrorCodes.BadRequest, 'month는 필수입니다. 형식: YYYY-MM'); + } + + const monthDate = new Date(`${month}-01`); // YYYY-MM 형식에서 Date 객체 생성 + if (isNaN(monthDate)) { + throw new CustomError(ErrorCodes.BadRequest, '올바른 month 형식이 아닙니다. 예: 2025-01'); + } + + const schedules = await getTrainerSchedulesByMonth(trainer.id, monthDate); + res.status(200).json(schedules); +}); + +// 관리하는 특정 회원 조회 +const getMemberByTrainerController = asyncHandler(async (req, res) => { + const { memberId } = req.params; + const trainer = req.role; + + const data = await getMemberByTrainer(trainer.id, parseInt(memberId)); + res.status(200).json(data); +}); + // 스케줄 제안 (트레이너) const proposeScheduleByTrainerController = asyncHandler(async (req, res) => { const { memberId, date, location } = req.body; @@ -40,6 +89,10 @@ const cancelScheduleByTrainerController = asyncHandler(async (req, res) => { }); module.exports = { + addMemberToTrainerController, + getTrainerMembersController, + getTrainerSchedulesByMonthController, + getMemberByTrainerController, proposeScheduleByTrainerController, acceptScheduleByTrainerController, rejectScheduleByTrainerController, diff --git a/src/middlewares/jwtMiddlewares.js b/src/middlewares/jwtMiddlewares.js index 5cdcbc9..1f92b48 100644 --- a/src/middlewares/jwtMiddlewares.js +++ b/src/middlewares/jwtMiddlewares.js @@ -22,9 +22,11 @@ const authenticateToken = asyncHandler(async (req, res, next) => { if (!token) { throw new CustomError(ErrorCodes.Unauthorized, 'Unauthorized: 토큰이 없습니다.'); // 토큰 없음 } - + + let decoded; + try { - const decoded = jwt.verify(token, SECRET_KEY); // 토큰 검증 + decoded = jwt.verify(token, SECRET_KEY); // 토큰 검증 console.log("Decoded Token:", decoded); } catch (error) { // 토큰 만료 에러 처리 @@ -43,8 +45,8 @@ const authenticateToken = asyncHandler(async (req, res, next) => { } req.user = user; // 검증된 사용자 데이터를 req.user에 저장 + next(); - }); module.exports = { authenticateToken }; \ No newline at end of file diff --git a/src/routers/index.js b/src/routers/index.js index 31d47f4..847905f 100644 --- a/src/routers/index.js +++ b/src/routers/index.js @@ -27,7 +27,7 @@ router.use("", userRouters); * name: Member * description: 회원 관련 엔드포인트 */ -router.use("member/", memberRouters); +router.use("/member", memberRouters); /** * @swagger @@ -35,6 +35,6 @@ router.use("member/", memberRouters); * name: Trainer * description: 트레이너 관련 엔드포인트 */ -router.use("trainer/", trainerRouters); +router.use("/trainer", trainerRouters); module.exports = router; \ No newline at end of file diff --git a/src/routers/memberRouters.js b/src/routers/memberRouters.js index b5f434a..4b6ac22 100644 --- a/src/routers/memberRouters.js +++ b/src/routers/memberRouters.js @@ -1,5 +1,7 @@ const express = require("express"); const { + getMemberSchedulesByMonthController, + getRelatedTrainersController, proposeScheduleByMemberController, acceptScheduleByMemberController, rejectScheduleByMemberController, @@ -9,13 +11,73 @@ const { authenticateToken } = require("../middlewares/jwtMiddlewares"); const { memberMiddleware } = require("../middlewares/roleMiddlewares"); const router = express.Router(); +/** + * @swagger + * /api/member/schedules: + * get: + * summary: "멤버가 자신의 스케줄 조회 (특정 한 달)" + * tags: [Member] + * security: + * - BearerAuth: [] + * parameters: + * - in: query + * name: month + * schema: + * type: string + * format: date + * description: "조회할 달 (YYYY-MM)" + * required: true + * responses: + * 200: + * description: "멤버의 스케줄 리스트 반환" + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Schedule" + */ +router.get('/schedules', authenticateToken, memberMiddleware, getMemberSchedulesByMonthController); + +/** + * @swagger + * /api/member/trainers: + * get: + * summary: "멤버가 자신과 관련 있는 트레이너 조회" + * tags: [Member] + * security: + * - BearerAuth: [] + * responses: + * 200: + * description: "멤버와 관련된 트레이너 리스트 반환" + * content: + * application/json: + * schema: + * type: array + * items: + * type: object + * properties: + * id: + * type: integer + * description: "트레이너 ID" + * name: + * type: string + * description: "트레이너 이름" + * email: + * type: string + * description: "트레이너 이메일" + * phoneNumber: + * type: string + * description: "트레이너 전화번호" + */ +router.get('/trainers', authenticateToken, memberMiddleware, getRelatedTrainersController); /** * @swagger * /api/member/schedules/propose: * post: * summary: "멤버가 트레이너에게 스케줄 제안" - * tags: [Member Schedules] + * tags: [Member] * security: * - BearerAuth: [] * requestBody: @@ -54,7 +116,7 @@ router.post("schedules/propose", authenticateToken, memberMiddleware, proposeSch * /api/member/schedules/{scheduleId}/accept: * put: * summary: "멤버가 트레이너가 제안한 스케줄 수락" - * tags: [Member Schedules] + * tags: [Member] * security: * - BearerAuth: [] * parameters: @@ -85,7 +147,7 @@ router.put( "/schedules/:scheduleId/accept", authenticateToken, memberMiddleware * /api/member/schedules/{scheduleId}/reject: * put: * summary: "멤버가 트레이너가 제안한 스케줄 거절" - * tags: [Member Schedules] + * tags: [Member] * security: * - BearerAuth: [] * parameters: @@ -118,7 +180,7 @@ router.put( * /api/member/schedules/{scheduleId}/cancel: * delete: * summary: "멤버가 스케줄 취소 또는 삭제" - * tags: [Member Schedules] + * tags: [Member] * security: * - BearerAuth: [] * parameters: diff --git a/src/routers/trainerRouters.js b/src/routers/trainerRouters.js index ce345ea..f6483c6 100644 --- a/src/routers/trainerRouters.js +++ b/src/routers/trainerRouters.js @@ -1,5 +1,9 @@ const express = require("express"); const { + addMemberToTrainerController, + getTrainerMembersController, + getTrainerSchedulesByMonthController, + getMemberByTrainerController, proposeScheduleByTrainerController, acceptScheduleByTrainerController, rejectScheduleByTrainerController, @@ -10,12 +14,172 @@ const { trainerMiddleware } = require("../middlewares/roleMiddlewares"); const router = express.Router(); +/** + * @swagger + * /api/trainer/members/{memberId}: + * post: + * summary: "트레이너가 멤버를 관리 목록에 추가" + * tags: [Trainer] + * security: + * - BearerAuth: [] + * parameters: + * - in: path + * name: memberId + * schema: + * type: integer + * required: true + * description: "추가할 멤버 ID" + * example: 1 + * responses: + * 201: + * description: "멤버가 관리 목록에 추가됨" + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "멤버가 트레이너의 관리 목록에 추가되었습니다." + * 404: + * description: "트레이너 또는 멤버를 찾을 수 없음" + * 409: + * description: "이미 관리 목록에 있는 멤버" + */ +router.post('/members/:memberId', authenticateToken, trainerMiddleware, addMemberToTrainerController); + +/** + * @swagger + * /api/trainer/members: + * get: + * summary: "트레이너가 관리하는 모든 회원 리스트" + * tags: [Trainer] + * security: + * - BearerAuth: [] + * parameters: + * - in: query + * name: page + * schema: + * type: integer + * description: "페이지 번호 (옵션, 기본값: 1)" + * - in: query + * name: limit + * schema: + * type: integer + * description: "한 페이지에 표시할 회원 수 (옵션, 기본값: 10)" + * responses: + * 200: + * description: "트레이너가 관리하는 회원 리스트 반환" + * content: + * application/json: + * schema: + * type: object + * properties: + * members: + * type: array + * items: + * type: object + * properties: + * id: + * type: integer + * description: "회원 ID" + * user: + * type: object + * properties: + * name: + * type: string + * description: "회원 이름" + * email: + * type: string + * description: "회원 이메일" + * phoneNumber: + * type: string + * description: "회원 전화번호" + * total: + * type: integer + * description: "총 회원 수" + */ +router.get("/members", authenticateToken, trainerMiddleware, getTrainerMembersController); + +/** + * @swagger + * /api/trainer/members/{memberId}: + * get: + * summary: "트레이너가 관리하는 특정 회원 조회" + * tags: [Trainer] + * security: + * - BearerAuth: [] + * parameters: + * - in: path + * name: memberId + * schema: + * type: integer + * required: true + * description: "조회할 회원의 ID" + * responses: + * 200: + * description: "특정 회원 정보 반환" + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: integer + * description: "회원 ID" + * user: + * type: object + * properties: + * name: + * type: string + * description: "회원 이름" + * email: + * type: string + * description: "회원 이메일" + * phoneNumber: + * type: string + * description: "회원 전화번호" + * 403: + * description: "트레이너의 관리 대상이 아님" + * 404: + * description: "회원이 존재하지 않음" + */ +router.get("/members/:memberId", authenticateToken, trainerMiddleware, getMemberByTrainerController); + +/** + * @swagger + * /api/trainer/schedules: + * get: + * summary: "트레이너의 모든 스케줄 (특정 한 달)" + * tags: [Trainer] + * security: + * - BearerAuth: [] + * parameters: + * - in: query + * name: month + * schema: + * type: string + * format: date + * description: "조회할 달 (YYYY-MM)" + * required: true + * responses: + * 200: + * description: "스케줄 리스트 반환" + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Schedule" + */ +router.get("/schedules", authenticateToken, trainerMiddleware, getTrainerSchedulesByMonthController); + /** * @swagger * /api/trainer/schedules/propose: * post: * summary: "트레이너가 멤버에게 스케줄 제안" - * tags: [Trainer Schedules] + * tags: [Trainer] * security: * - BearerAuth: [] * requestBody: @@ -54,7 +218,7 @@ router.post("/schedules/propose", authenticateToken, trainerMiddleware, proposeS * /api/trainer/schedules/{scheduleId}/accept: * put: * summary: "트레이너가 멤버가 제안한 스케줄 수락" - * tags: [Trainer Schedules] + * tags: [Trainer] * security: * - BearerAuth: [] * parameters: @@ -85,7 +249,7 @@ router.put( "/schedules/:scheduleId/accept", authenticateToken, trainerMiddlewar * /api/trainer/schedules/{scheduleId}/reject: * put: * summary: "트레이너가 멤버가 제안한 스케줄 거절" - * tags: [Trainer Schedules] + * tags: [Trainer] * security: * - BearerAuth: [] * parameters: @@ -116,7 +280,7 @@ router.put( "/schedules/:scheduleId/reject", authenticateToken, trainerMiddlewar * /api/trainer/schedules/{scheduleId}/cancel: * delete: * summary: "트레이너가 스케줄 취소 또는 삭제" - * tags: [Trainer Schedules] + * tags: [Trainer] * security: * - BearerAuth: [] * parameters: diff --git a/src/routers/userRouters.js b/src/routers/userRouters.js index fcf4cba..95d6141 100644 --- a/src/routers/userRouters.js +++ b/src/routers/userRouters.js @@ -6,7 +6,7 @@ const { updateUserController, deleteUserController, } = require("../controllers/userController"); -const { authenticateToken } = require("../middlewares/jwtMiddlewares") +const { authenticateToken } = require("../middlewares/jwtMiddlewares"); const router = express.Router(); @@ -66,6 +66,8 @@ router.post("/login", loginUserController); * get: * summary: "사용자 조회" * tags: [User] + * security: + * - BearerAuth: [] # 인증 추가 * responses: * 200: * description: "사용자 정보 반환" @@ -74,7 +76,7 @@ router.post("/login", loginUserController); * schema: * $ref: "#/components/schemas/User" */ -router.get("/user", authenticateToken , getUserByIdController); +router.get("/user", authenticateToken, getUserByIdController); /** * @swagger @@ -82,6 +84,8 @@ router.get("/user", authenticateToken , getUserByIdController); * put: * summary: "사용자 수정" * tags: [User] + * security: + * - BearerAuth: [] # 인증 추가 * requestBody: * required: true * content: @@ -96,19 +100,21 @@ router.get("/user", authenticateToken , getUserByIdController); * schema: * $ref: "#/components/schemas/User" */ -router.put("/user", authenticateToken , updateUserController); +router.put("/user", authenticateToken, updateUserController); /** * @swagger - * /api/user/{id}: + * /api/user: * delete: * summary: "사용자 삭제" * tags: [User] + * security: + * - BearerAuth: [] # 인증 추가 * responses: * 204: * description: "삭제 성공" */ -router.delete("/user", authenticateToken , deleteUserController); +router.delete("/user", authenticateToken, deleteUserController); module.exports = router; @@ -188,4 +194,9 @@ module.exports = router; * name: * type: string * description: "사용자 이름 (선택)" + * securitySchemes: + * BearerAuth: + * type: http + * scheme: bearer + * bearerFormat: JWT */ \ No newline at end of file diff --git a/src/services/memberService.js b/src/services/memberService.js index 71bc622..4fed6b7 100644 --- a/src/services/memberService.js +++ b/src/services/memberService.js @@ -24,6 +24,48 @@ const checkOrCreateMember = async (user) => { } }; +const getMemberSchedulesByMonth = async (memberId, month) => { + const startOfMonth = new Date(month); + startOfMonth.setDate(1); // 달의 첫 날 + const endOfMonth = new Date(startOfMonth); + endOfMonth.setMonth(endOfMonth.getMonth() + 1); // 다음 달의 첫 날 + + const schedules = await prisma.schedule.findMany({ + where: { + memberId, + date: { gte: startOfMonth, lt: endOfMonth }, + }, + orderBy: { date: 'asc' }, + }); + + return schedules; +}; + +const getRelatedTrainers = async (memberId) => { + const trainers = await prisma.trainer.findMany({ + where: { + members: { some: { id: memberId } }, + }, + include: { + user: { + select: { + id: true, + name: true, + email: true, + phoneNumber: true, + }, + }, + }, + }); + + return trainers.map((trainer) => ({ + id: trainer.id, + name: trainer.user.name, + email: trainer.user.email, + phoneNumber: trainer.user.phoneNumber, + })); +}; + // 스케줄 상태 열거형 const ScheduleStatus = { MEMBER_PROPOSED: "MEMBER_PROPOSED", @@ -136,6 +178,8 @@ const cancelSchedule = async (memberId, scheduleId) => { module.exports = { checkOrCreateMember, + getMemberSchedulesByMonth, + getRelatedTrainers , proposeScheduleByMember, acceptScheduleByMember, rejectScheduleByMember, diff --git a/src/services/trainerService.js b/src/services/trainerService.js index d5844eb..f09306f 100644 --- a/src/services/trainerService.js +++ b/src/services/trainerService.js @@ -24,6 +24,131 @@ const checkOrCreateTrainer = async (user) => { } }; +const addMemberToTrainer = async (trainerId, memberId) => { + // 멤버와 트레이너가 존재하는지 확인 + const [trainer, member] = await Promise.all([ + prisma.trainer.findUnique({ + where: { id: trainerId }, + }), + prisma.member.findUnique({ + where: { id: memberId }, + }), + ]); + + if (!trainer) { + throw new CustomError(ErrorCodes.NotFound, '트레이너를 찾을 수 없습니다.'); + } + + if (!member) { + throw new CustomError(ErrorCodes.NotFound, '멤버를 찾을 수 없습니다.'); + } + + // 멤버가 이미 트레이너의 관리 목록에 있는지 확인 + const existingRelation = await prisma.trainer.findFirst({ + where: { + id: trainerId, + members: { some: { id: memberId } }, + }, + }); + + if (existingRelation) { + throw new CustomError(ErrorCodes.Conflict, '해당 멤버는 이미 트레이너의 관리 목록에 있습니다.'); + } + + // 멤버를 트레이너의 관리 목록에 추가 + await prisma.trainer.update({ + where: { id: trainerId }, + data: { + members: { + connect: { id: memberId }, + }, + }, + }); + + return { message: '멤버가 트레이너의 관리 목록에 추가되었습니다.' }; +}; + +// 트레이너가 관리하는 모든 회원 리스트 (페이지네이션 포함) +const getTrainerMembers = async (trainerId, page = 1, limit = 10) => { + const offset = (page - 1) * limit; + + const [members, total] = await Promise.all([ + prisma.member.findMany({ + where: { trainers: { some: { id: trainerId } } }, + skip: offset, + take: limit, + include: { + user: { + select: { + name: true, + email: true, + phoneNumber: true, + }, + }, + }, + }), + prisma.member.count({ + where: { trainers: { some: { id: trainerId } } }, + }), + ]); + + return { members, total, page, totalPages: Math.ceil(total / limit) }; +}; + + +// 트레이너가 관리하는 회원 정보 +const getMemberByTrainer = async (trainerId, memberId) => { + // 해당 회원이 트레이너의 관리 하에 있는지 확인 + const member = await prisma.member.findFirst({ + where: { + id: memberId, + trainers: { some: { id: trainerId } }}, + include: { + user: { + select: { + name: true, + email: true, + phoneNumber: true, + }, + }, + } + }); + + if (!member) { + throw new CustomError(ErrorCodes.Forbidden, "해당 회원은 트레이너의 관리 대상이 아닙니다."); + } + + // 회원의 모든 스케줄 조회 + const schedules = await prisma.schedule.findMany({ + where: { memberId }, + orderBy: { date: 'asc' }, + }); + + const data = { + name : member.user.name, + schedules : schedules + } + return data; +}; + +// 트레이너의 스켸줄 조회 (한달 치) +const getTrainerSchedulesByMonth = async (trainerId, month) => { + const startOfMonth = new Date(month); + startOfMonth.setDate(1); // 달의 첫 날 + const endOfMonth = new Date(startOfMonth); + endOfMonth.setMonth(endOfMonth.getMonth() + 1); // 다음 달의 첫 날 + + const schedules = await prisma.schedule.findMany({ + where: { + trainerId, + date: { gte: startOfMonth, lt: endOfMonth }, + }, + orderBy: { date: 'asc' }, + }); + + return schedules; +}; + // 스케줄 상태 열거형 const ScheduleStatus = { trainer_PROPOSED: "trainer_PROPOSED", @@ -136,6 +261,10 @@ const cancelScheduleByTrainer = async (trainerId, scheduleId) => { module.exports = { checkOrCreateTrainer, + addMemberToTrainer, + getTrainerMembers, + getMemberByTrainer, + getTrainerSchedulesByMonth, proposeScheduleByTrainer, acceptScheduleByTrainer, rejectScheduleByTrainer, diff --git a/src/services/userService.js b/src/services/userService.js index 1240809..8d686ab 100644 --- a/src/services/userService.js +++ b/src/services/userService.js @@ -65,7 +65,7 @@ const loginUser = async (username, password) => { { expiresIn: '1h' } ); - return { token, user: removePasswordField(user) }; + return { token, user : removePasswordField(user) }; }; // 사용자 조회 @@ -73,7 +73,6 @@ const getUserById = async (id) => { const user = await prisma.user.findUnique({ where: { id: parseInt(id) }, }); - return removePasswordField(user); }; diff --git a/src/struct/memberStruct.js b/src/struct/memberStruct.js new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/asyncHandler.js b/src/utils/asyncHandler.js index 3ba444f..4f68ebc 100644 --- a/src/utils/asyncHandler.js +++ b/src/utils/asyncHandler.js @@ -2,7 +2,7 @@ function asyncHandler(handler) { return async function (req, res, next) { try { - await handler(req, res); + await handler(req, res , next); } catch (error) { next(error); } diff --git a/src/utils/dataFormat.js b/src/utils/dataFormat.js new file mode 100644 index 0000000..10263e7 --- /dev/null +++ b/src/utils/dataFormat.js @@ -0,0 +1,16 @@ +// 날짜를 YYYY-MM-DD 형식으로 변환 +function formatDateToString(date) { + const d = new Date(date); + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, "0"); // 월은 0부터 시작하므로 +1 + const day = String(d.getDate()).padStart(2, "0"); + + return `${year}-${month}-${day}`; +} + + // string을 date형식으로 반환 +function formatStringToDate(string) { + return new Date(string); +} + +module.exports = { formatDateToString, formatStringToDate }; \ No newline at end of file diff --git a/src/utils/password.js b/src/utils/password.js index 3914b23..b6def7d 100644 --- a/src/utils/password.js +++ b/src/utils/password.js @@ -14,7 +14,7 @@ async function comparePassword(plainPassword, hashedPassword) { } // 비밀번호 필드 제거 유틸리티 함수 -async function removePasswordField(user) { +function removePasswordField(user) { const { password, ...safeUser } = user; return safeUser; }; From 6441124a20afb8b694a9af49aa00a5bad69ac9f6 Mon Sep 17 00:00:00 2001 From: chan000518 Date: Tue, 7 Jan 2025 03:11:02 +0900 Subject: [PATCH 09/11] =?UTF-8?q?chore:=20=EB=8F=84=EC=BB=A4=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 노드 버전 업데이트 프리즈마 관련 추가 Related to: #6 --- Dockerfile | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 61dc15e..5942aba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ -# 베이스 이미지 설정 -FROM node:16 +# Node.js 18.18.0 이미지를 사용 +FROM node:18.18.0 # 작업 디렉토리 설정 WORKDIR /usr/src/app @@ -8,11 +8,21 @@ WORKDIR /usr/src/app COPY package*.json ./ RUN npm install -# 소스 코드 복사 +# Prisma CLI 설치 (글로벌 또는 프로젝트 내 설치에 따라 조정) +RUN npx prisma --version + +# Prisma schema 파일 복사 (Prisma CLI 실행 전에 필요) +COPY prisma ./prisma + +# Prisma 명령어 실행 (예: generate와 migrate) +RUN npx prisma generate +RUN npx prisma migrate deploy + +# 애플리케이션 소스 코드 복사 COPY . . -# 앱 포트 설정 +# 포트 노출 EXPOSE 3000 -# 앱 실행 명령어 +# 애플리케이션 시작 CMD ["npm", "start"] \ No newline at end of file From 300caec81b08d8ab7591c6f0695da452bd224457 Mon Sep 17 00:00:00 2001 From: chan000518 Date: Tue, 7 Jan 2025 03:21:35 +0900 Subject: [PATCH 10/11] =?UTF-8?q?chore:=20=EA=B9=83=ED=97=88=EB=B8=8C=20?= =?UTF-8?q?=EC=95=A1=EC=85=98=20=EC=9B=8C=ED=81=AC=20=ED=94=8C=EB=A1=9C,?= =?UTF-8?q?=20=EB=8F=84=EC=BB=A4=20=ED=8C=8C=EC=9D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 환경변수로 url, jwt를 처리해서 빌드하게 수정 --- .github/workflows/main.yml | 5 ++++- Dockerfile | 8 ++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8479e31..d686dcd 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -25,7 +25,10 @@ jobs: - name: Build and Push Docker image run: | IMAGE_NAME=${{ secrets.DOCKER_USERNAME }}/docker-test-app - docker build -t $IMAGE_NAME:latest . + docker build \ + --build-arg DATABASE_URL=${{ secrets.DATABASE_URL }} \ + --build-arg JWT_SECRET=${{ secrets.JWT_SECRET }} \ + -t $IMAGE_NAME:latest . docker push $IMAGE_NAME:latest deploy: diff --git a/Dockerfile b/Dockerfile index 5942aba..a1298b7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,14 @@ # Node.js 18.18.0 이미지를 사용 FROM node:18.18.0 +# 빌드 타임 환경 변수 설정 +ARG DATABASE_URL +ARG JWT_SECRET + +# 런타임 환경 변수 설정 +ENV DATABASE_URL=$DATABASE_URL +ENV JWT_SECRET=$JWT_SECRET + # 작업 디렉토리 설정 WORKDIR /usr/src/app From c8d2f55b28c5ea11b9da53d0f5dbe64d7d103c6f Mon Sep 17 00:00:00 2001 From: chan000518 Date: Wed, 15 Jan 2025 20:54:47 +0900 Subject: [PATCH 11/11] =?UTF-8?q?feat:=20=EC=8A=A4=ED=82=A4=EB=A7=88=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 회원과 트레이너간 다대다 테이블 생성 스켸줄에 운동 타겟과 상세 운동, 코멘트 추가 단, 마이그레이션은 미진행 Related to: #1 --- .github/workflows/main.yml | 3 -- .../migration.sql | 33 +++++++++++++++++++ prisma/schema.prisma | 32 ++++++++++++++++-- src/controllers/memberController.js | 3 +- src/services/memberService.js | 5 ++- src/services/trainerService.js | 22 ++++++------- 6 files changed, 80 insertions(+), 18 deletions(-) create mode 100644 prisma/migrations/20250115105416_add_trainer_member/migration.sql diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d686dcd..8b7e802 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -4,9 +4,6 @@ on: push: branches: - main - pull_request: - branches: - - main jobs: build-and-push: diff --git a/prisma/migrations/20250115105416_add_trainer_member/migration.sql b/prisma/migrations/20250115105416_add_trainer_member/migration.sql new file mode 100644 index 0000000..a045e4d --- /dev/null +++ b/prisma/migrations/20250115105416_add_trainer_member/migration.sql @@ -0,0 +1,33 @@ +/* + Warnings: + + - You are about to drop the `_MemberToTrainer` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "_MemberToTrainer" DROP CONSTRAINT "_MemberToTrainer_A_fkey"; + +-- DropForeignKey +ALTER TABLE "_MemberToTrainer" DROP CONSTRAINT "_MemberToTrainer_B_fkey"; + +-- DropTable +DROP TABLE "_MemberToTrainer"; + +-- CreateTable +CREATE TABLE "TrainerMember" ( + "id" SERIAL NOT NULL, + "memberId" INTEGER NOT NULL, + "trainerId" INTEGER NOT NULL, + "ptStartDate" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TrainerMember_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "TrainerMember_memberId_trainerId_key" ON "TrainerMember"("memberId", "trainerId"); + +-- AddForeignKey +ALTER TABLE "TrainerMember" ADD CONSTRAINT "TrainerMember_memberId_fkey" FOREIGN KEY ("memberId") REFERENCES "Member"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TrainerMember" ADD CONSTRAINT "TrainerMember_trainerId_fkey" FOREIGN KEY ("trainerId") REFERENCES "Trainer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8e0b275..8a6ceef 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -33,7 +33,7 @@ model Member { id Int @id @default(autoincrement()) userId Int @unique user User @relation(fields: [userId], references: [id]) - trainers Trainer[] // 연결된 트레이너 목록 + trainers TrainerMember[] // 연결된 트레이너 목록 schedules Schedule[] // 관련된 스케줄 목록 } @@ -41,21 +41,49 @@ model Trainer { id Int @id @default(autoincrement()) userId Int @unique user User @relation(fields: [userId], references: [id]) - members Member[] // 관리하는 회원 목록 + members TrainerMember[] // 관리하는 회원 목록 schedules Schedule[] // PT 스케줄 } +model TrainerMember { + id Int @id @default(autoincrement()) + memberId Int + trainerId Int + member Member @relation(fields: [memberId], references: [id]) + trainer Trainer @relation(fields: [trainerId], references: [id]) + + // 추가 정보 + ptStartDate DateTime // PT 시작일 + + // 고유 제약조건 + @@unique([memberId, trainerId]) // 한 회원-트레이너 조합은 중복될 수 없음 +} + model Schedule { id Int @id @default(autoincrement()) date DateTime // PT 날짜 및 시간 location String? // PT 장소 (선택적) status Status @default(SCHEDULED) // 일정 상태 + trainingTarget String + comment String? + exerciseDetails ExerciseDetail[] memberId Int trainerId Int member Member @relation(fields: [memberId], references: [id]) trainer Trainer @relation(fields: [trainerId], references: [id]) } +model ExerciseDetail { + id Int @id @default(autoincrement()) + scheduleId Int // FitnessGoal과 연결 + schedule Schedule @relation(fields: [scheduleId], references: [id]) + exerciseName String // 운동 이름 (예: 스쿼트, 데드리프트) + reps Int // 반복 횟수 + sets Int // 세트 수 + weight Float? // 무게 (선택적, 예: kg) + duration Int? // 운동 시간 (선택적, 예: 분) +} + enum Role { TRAINER MEMBER diff --git a/src/controllers/memberController.js b/src/controllers/memberController.js index c1e9727..5a10627 100644 --- a/src/controllers/memberController.js +++ b/src/controllers/memberController.js @@ -8,7 +8,7 @@ const { } = require("../services/memberService"); const asyncHandler = require('../utils/asyncHandler'); - + // 멤버가 자신의 스케줄 조회 (특정 한 달) const getMemberSchedulesByMonthController = asyncHandler(async (req, res) => { const { month } = req.query; @@ -24,6 +24,7 @@ const getMemberSchedulesByMonthController = asyncHandler(async (req, res) => { } const schedules = await getMemberSchedulesByMonth(member.id, monthDate); + res.status(200).json(schedules); }); diff --git a/src/services/memberService.js b/src/services/memberService.js index 4fed6b7..a31bcfd 100644 --- a/src/services/memberService.js +++ b/src/services/memberService.js @@ -3,6 +3,7 @@ const { ErrorCodes, CustomError } = require("../middlewares/errorHandler"); const prisma = new PrismaClient(); +// 멤버 엔티티가 있는 지 확인하고 없으면 생성 const checkOrCreateMember = async (user) => { if (user.role === 'MEMBER') { // 회원(Member) 엔트리 확인 @@ -24,6 +25,7 @@ const checkOrCreateMember = async (user) => { } }; +// 이번 달의 멤버의 스켸줄 const getMemberSchedulesByMonth = async (memberId, month) => { const startOfMonth = new Date(month); startOfMonth.setDate(1); // 달의 첫 날 @@ -41,10 +43,11 @@ const getMemberSchedulesByMonth = async (memberId, month) => { return schedules; }; +// 관련된 트레이너들을 가져옴 const getRelatedTrainers = async (memberId) => { const trainers = await prisma.trainer.findMany({ where: { - members: { some: { id: memberId } }, + members: { some: { memberId: memberId } }, }, include: { user: { diff --git a/src/services/trainerService.js b/src/services/trainerService.js index f09306f..f885c1a 100644 --- a/src/services/trainerService.js +++ b/src/services/trainerService.js @@ -24,6 +24,7 @@ const checkOrCreateTrainer = async (user) => { } }; +//트레이너가 멤버 추가 const addMemberToTrainer = async (trainerId, memberId) => { // 멤버와 트레이너가 존재하는지 확인 const [trainer, member] = await Promise.all([ @@ -44,10 +45,10 @@ const addMemberToTrainer = async (trainerId, memberId) => { } // 멤버가 이미 트레이너의 관리 목록에 있는지 확인 - const existingRelation = await prisma.trainer.findFirst({ + const existingRelation = await prisma.trainerMember.findFirst({ where: { - id: trainerId, - members: { some: { id: memberId } }, + trainerId: trainerId, + memberId: memberId, }, }); @@ -56,12 +57,11 @@ const addMemberToTrainer = async (trainerId, memberId) => { } // 멤버를 트레이너의 관리 목록에 추가 - await prisma.trainer.update({ - where: { id: trainerId }, + await prisma.trainerMember.create({ data: { - members: { - connect: { id: memberId }, - }, + trainerId: trainerId, + memberId: memberId, + ptStartDate: new Date(), // PT 시작일은 현재 날짜로 설정 (필요 시 변경 가능) }, }); @@ -74,7 +74,7 @@ const getTrainerMembers = async (trainerId, page = 1, limit = 10) => { const [members, total] = await Promise.all([ prisma.member.findMany({ - where: { trainers: { some: { id: trainerId } } }, + where: { trainers: { some: { trainerId: trainerId } } }, skip: offset, take: limit, include: { @@ -102,7 +102,7 @@ const getMemberByTrainer = async (trainerId, memberId) => { const member = await prisma.member.findFirst({ where: { id: memberId, - trainers: { some: { id: trainerId } }}, + trainers: { some: { trainerId: trainerId } }}, include: { user: { select: { @@ -151,7 +151,7 @@ const getTrainerSchedulesByMonth = async (trainerId, month) => { // 스케줄 상태 열거형 const ScheduleStatus = { - trainer_PROPOSED: "trainer_PROPOSED", + MEMBER_PROPOSED: "MEMBER_PROPOSED", TRAINER_PROPOSED: "TRAINER_PROPOSED", SCHEDULED: "SCHEDULED", REJECTED: "REJECTED",