diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8479e31..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: @@ -25,7 +22,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 61dc15e..a1298b7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,13 @@ -# 베이스 이미지 설정 -FROM node:16 +# 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 @@ -8,11 +16,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 diff --git a/package.json b/package.json index 08d9154..9dae1b5 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,15 @@ "license": "ISC", "description": "", "dependencies": { + "@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", + "prisma": "^6.1.0", + "superstruct": "^2.0.2", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1" }, 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/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/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/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/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 new file mode 100644 index 0000000..8a6ceef --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,99 @@ +// 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") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + username 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 TrainerMember[] // 연결된 트레이너 목록 + schedules Schedule[] // 관련된 스케줄 목록 +} + +model Trainer { + id Int @id @default(autoincrement()) + userId Int @unique + user User @relation(fields: [userId], references: [id]) + 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 +} + +enum Status { + MEMBER_PROPOSED // 멤버가 제안한 상태 + TRAINER_PROPOSED // 트레이너가 제안한 상태 + REJECTED // 거절 상태 + SCHEDULED // PT가 확정된 상태 + COMPLETED // 완료된 상태 + CANCELED // 취소된 상태 +} \ No newline at end of file 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/memberController.js b/src/controllers/memberController.js new file mode 100644 index 0000000..5a10627 --- /dev/null +++ b/src/controllers/memberController.js @@ -0,0 +1,77 @@ +const { + getMemberSchedulesByMonth, + getRelatedTrainers, + proposeScheduleByMember, + acceptScheduleByMember, + rejectScheduleByMember, + cancelScheduleByMember, +} = require("../services/memberService"); + +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; + 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 = { + getMemberSchedulesByMonthController, + getRelatedTrainersController, + 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..fcd656f --- /dev/null +++ b/src/controllers/trainerController.js @@ -0,0 +1,100 @@ +const { + addMemberToTrainer, + getTrainerMembers, + getMemberByTrainer, + getTrainerSchedulesByMonth, + proposeScheduleByTrainer, + acceptScheduleByTrainer, + rejectScheduleByTrainer, + cancelScheduleByTrainer, +} = require("../services/trainerService"); + +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; + 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 = { + addMemberToTrainerController, + getTrainerMembersController, + getTrainerSchedulesByMonthController, + getMemberByTrainerController, + proposeScheduleByTrainerController, + acceptScheduleByTrainerController, + rejectScheduleByTrainerController, + cancelScheduleByTrainerController, +}; \ No newline at end of file diff --git a/src/controllers/userController.js b/src/controllers/userController.js new file mode 100644 index 0000000..600063e --- /dev/null +++ b/src/controllers/userController.js @@ -0,0 +1,48 @@ +const { + createUser, + loginUser, + getUserById, + updateUser, + deleteUser, +} = require('../services/userService'); + +const asyncHandler = require('../utils/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.user.id); + res.status(200).json(user); +}); + +// 사용자 수정 +const updateUserController = asyncHandler( async (req, res, next) => { + const user = await updateUser(req.user.id, req.body); + res.status(200).json(user); +}); + +// 사용자 삭제 +const deleteUserController = asyncHandler( async (req, res, next) => { + await deleteUser(req.user.id); + res.status(204).json({message: "삭제 성공"}); +}); + +module.exports = { + createUserController, + loginUserController, + getUserByIdController, + updateUserController, + deleteUserController, +}; \ No newline at end of file diff --git a/src/middlewares/errorHandler.js b/src/middlewares/errorHandler.js index 20a826f..985d286 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"); // 에러 코드 및 기본 메세지 // 필요에 따라 추가 및 수정 @@ -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: "예상치 못한 오류가 발생했습니다", - }); + // 예상치 못한 에러의 경우 + return res.status(500).json({ + message: "예상치 못한 오류가 발생했습니다", + }); + } + catch { + // 에러 처리중 예상치 못한 에러의 경우 + return res.status(500).json({ + message: "에러 처리 중 예상치 못한 오류가 발생했습니다", + }); + } }; // 사용 예시 diff --git a/src/middlewares/jwtMiddlewares.js b/src/middlewares/jwtMiddlewares.js new file mode 100644 index 0000000..1f92b48 --- /dev/null +++ b/src/middlewares/jwtMiddlewares.js @@ -0,0 +1,52 @@ +const jwt = require("jsonwebtoken"); +const { ErrorCodes, CustomError } = require("./errorHandler"); +const asyncHandler = require("../utils/asyncHandler"); +const { getUserById } = require("../services/userService"); + +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]; + } else if (req.query.token) { + token = req.query.token; + } + + if (!token) { + throw new CustomError(ErrorCodes.Unauthorized, 'Unauthorized: 토큰이 없습니다.'); // 토큰 없음 + } + + let decoded; + + try { + decoded = jwt.verify(token, SECRET_KEY); // 토큰 검증 + console.log("Decoded Token:", decoded); + } catch (error) { + // 토큰 만료 에러 처리 + if (error.name === "TokenExpiredError") { + throw new CustomError(ErrorCodes.Unauthorized, "Unauthorized: 토큰이 만료되었습니다."); + } + + // 기타 토큰 검증 실패 에러 처리 + 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 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/models/.gitkeep b/src/models/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/routers/index.js b/src/routers/index.js index 8bb76f3..847905f 100644 --- a/src/routers/index.js +++ b/src/routers/index.js @@ -1,6 +1,9 @@ const express = require("express"); const router = express.Router(); const testRouters = require("./testRouters"); +const userRouters = require("./userRouters"); +const memberRouters = require("./memberRouters"); +const trainerRouters = require("./trainerRouters"); /** * @swagger @@ -10,4 +13,28 @@ const testRouters = require("./testRouters"); */ router.use("", testRouters); +/** + * @swagger + * tags: + * name: User + * description: 기본 유저 관련 엔드포인트 + */ +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..4b6ac22 --- /dev/null +++ b/src/routers/memberRouters.js @@ -0,0 +1,254 @@ +const express = require("express"); +const { + getMemberSchedulesByMonthController, + getRelatedTrainersController, + 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: + * 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] + * 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] + * 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] + * 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] + * 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/testRouters.js b/src/routers/testRouters.js index 39904a9..4675c52 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("../utils/asyncHandler"); const { ErrorCodes, CustomError } = require("../middlewares/errorHandler"); const router = express.Router(); diff --git a/src/routers/trainerRouters.js b/src/routers/trainerRouters.js new file mode 100644 index 0000000..f6483c6 --- /dev/null +++ b/src/routers/trainerRouters.js @@ -0,0 +1,354 @@ +const express = require("express"); +const { + addMemberToTrainerController, + getTrainerMembersController, + getTrainerSchedulesByMonthController, + getMemberByTrainerController, + proposeScheduleByTrainerController, + acceptScheduleByTrainerController, + rejectScheduleByTrainerController, + cancelScheduleByTrainerController, +} = require("../controllers/trainerController"); +const { authenticateToken } = require("../middlewares/jwtMiddlewares"); +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] + * 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] + * 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] + * 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] + * 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 new file mode 100644 index 0000000..95d6141 --- /dev/null +++ b/src/routers/userRouters.js @@ -0,0 +1,202 @@ +const express = require("express"); +const { + createUserController, + loginUserController, + getUserByIdController, + updateUserController, + deleteUserController, +} = require("../controllers/userController"); +const { authenticateToken } = require("../middlewares/jwtMiddlewares"); + +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: + * get: + * summary: "사용자 조회" + * tags: [User] + * security: + * - BearerAuth: [] # 인증 추가 + * responses: + * 200: + * description: "사용자 정보 반환" + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/User" + */ +router.get("/user", authenticateToken, getUserByIdController); + +/** + * @swagger + * /api/user: + * put: + * summary: "사용자 수정" + * tags: [User] + * security: + * - BearerAuth: [] # 인증 추가 + * 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", authenticateToken, updateUserController); + +/** + * @swagger + * /api/user: + * delete: + * summary: "사용자 삭제" + * tags: [User] + * security: + * - BearerAuth: [] # 인증 추가 + * responses: + * 204: + * description: "삭제 성공" + */ +router.delete("/user", authenticateToken, 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: "사용자 이름 (선택)" + * 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 new file mode 100644 index 0000000..a31bcfd --- /dev/null +++ b/src/services/memberService.js @@ -0,0 +1,190 @@ +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 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: { memberId: 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", + 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, + getMemberSchedulesByMonth, + getRelatedTrainers , + 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..f885c1a --- /dev/null +++ b/src/services/trainerService.js @@ -0,0 +1,272 @@ +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 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.trainerMember.findFirst({ + where: { + trainerId: trainerId, + memberId: memberId, + }, + }); + + if (existingRelation) { + throw new CustomError(ErrorCodes.Conflict, '해당 멤버는 이미 트레이너의 관리 목록에 있습니다.'); + } + + // 멤버를 트레이너의 관리 목록에 추가 + await prisma.trainerMember.create({ + data: { + trainerId: trainerId, + memberId: memberId, + ptStartDate: new Date(), // PT 시작일은 현재 날짜로 설정 (필요 시 변경 가능) + }, + }); + + 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: { trainerId: 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: { trainerId: 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 = { + MEMBER_PROPOSED: "MEMBER_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, + addMemberToTrainer, + getTrainerMembers, + getMemberByTrainer, + getTrainerSchedulesByMonth, + proposeScheduleByTrainer, + acceptScheduleByTrainer, + rejectScheduleByTrainer, + cancelScheduleByTrainer, +}; \ No newline at end of file diff --git a/src/services/userService.js b/src/services/userService.js new file mode 100644 index 0000000..8d686ab --- /dev/null +++ b/src/services/userService.js @@ -0,0 +1,102 @@ +const { PrismaClient } = require('@prisma/client'); +const { assert } = require('superstruct'); +const { createUserStruct,updateUserStruct } = require('../struct/userStruct'); +const { hashPassword, comparePassword , removePasswordField } = 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 + + const user = await prisma.user.create({ + 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); +}; + +// 사용자 로그인 +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_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 }, + secret, + { expiresIn: '1h' } + ); + + return { token, user : removePasswordField(user) }; +}; + +// 사용자 조회 +const getUserById = async (id) => { + const user = await prisma.user.findUnique({ + where: { id: parseInt(id) }, + }); + return removePasswordField(user); +}; + +// 사용자 수정 +const updateUser = async (id, data) => { + assert(data, updateUserStruct); + + const user = await prisma.user.update({ + where: { id: parseInt(id) }, + data, + }); + + return removePasswordField(user); +}; + +// 사용자 삭제 +const deleteUser = async (id) => { + 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/controllers/.gitkeep b/src/struct/memberStruct.js similarity index 100% rename from src/controllers/.gitkeep rename to src/struct/memberStruct.js diff --git a/src/struct/userStruct.js b/src/struct/userStruct.js new file mode 100644 index 0000000..f57922c --- /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) || '잘못된 이메일 형식. 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) || + '비밀번호는 최소 8자 이상이어야 하며, 적어도 하나의 문자와 하나의 숫자를 포함해야 합니다.' + ); +}); + +// 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/middlewares/asyncHandler.js b/src/utils/asyncHandler.js similarity index 82% rename from src/middlewares/asyncHandler.js rename to src/utils/asyncHandler.js index 3ba444f..4f68ebc 100644 --- a/src/middlewares/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 new file mode 100644 index 0000000..b6def7d --- /dev/null +++ b/src/utils/password.js @@ -0,0 +1,22 @@ +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); +} + +// 비밀번호 필드 제거 유틸리티 함수 +function removePasswordField(user) { + const { password, ...safeUser } = user; + return safeUser; +}; + +module.exports = { hashPassword, comparePassword , removePasswordField}; \ No newline at end of file