From e4832d495a8b7e72939bacc2b5e17c0c9976ba18 Mon Sep 17 00:00:00 2001 From: faker Date: Thu, 12 Feb 2026 20:43:17 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=ED=8C=80=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EC=83=89=EC=83=81=20=EB=AC=B8=EC=A0=9C=20(#286)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/folder.service.js | 8 ++++++++ src/services/task.service.js | 31 +++++++++++++++++++++++++++++++ src/swagger/swagger.yml | 23 ++++++++++++++++++----- 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/src/services/folder.service.js b/src/services/folder.service.js index 118b2e0..cf8b041 100644 --- a/src/services/folder.service.js +++ b/src/services/folder.service.js @@ -29,6 +29,10 @@ class FolderService { if (!folder) throw new NotFoundError("FOLDER_NOT_FOUND", "해당 폴더를 찾을 수 없습니다."); if (folder.userId !== userId) throw new ForbiddenError("FORBIDDEN", "수정 권한이 없습니다."); + if (folder.folderTitle === "팀") { + throw new BadRequestError("PROTECTED_FOLDER", "'팀' 폴더는 수정할 수 없습니다."); + } + const updateData = FolderDto.updateBodyToFolderDto(body); if (Object.keys(updateData).length === 0) { @@ -55,6 +59,10 @@ class FolderService { if (!folder) throw new NotFoundError("FOLDER_NOT_FOUND", "해당 폴더를 찾을 수 없습니다."); if (folder.userId !== userId) throw new ForbiddenError("FORBIDDEN", "삭제 권한이 없습니다."); + if (folder.folderTitle === "팀") { + throw new BadRequestError("PROTECTED_FOLDER", "'팀' 폴더는 삭제할 수 없습니다."); + } + await folderRepository.removeFolder(userId, folderId); } } diff --git a/src/services/task.service.js b/src/services/task.service.js index a323547..b711970 100644 --- a/src/services/task.service.js +++ b/src/services/task.service.js @@ -38,6 +38,37 @@ class TaskService { if (!folder) throw new NotFoundError("존재하지 않는 폴더입니다."); } + let targetFolderId = folderId; // 최종적으로 저장될 폴더 ID + + if (taskData.type === 'TEAM') { + // 1. 팀 과제라면 무조건 '팀' 폴더를 찾습니다. + const teamFolder = await prisma.folder.findFirst({ + where: { + userId: userId, + folderTitle: "팀" + } + }); + + if (!teamFolder) { + throw new NotFoundError("팀 과제 전용 폴더를 찾을 수 없습니다."); + } + + // 2. 강제로 '팀' 폴더 ID로 + targetFolderId = teamFolder.id; + + } else if (folderId) { + // 3. 개인 과제인데 폴더를 선택한 경우 -> 유효성 및 권한 검사 + const folder = await taskRepository.findFolderById(folderId); + + if (!folder) { + throw new NotFoundError("존재하지 않는 폴더입니다."); + } + + if (folder.userId !== userId) { + throw new ForbiddenError("권한이 없는 폴더입니다."); + } + } + return await prisma.$transaction(async (tx) => { // 과제 생성 const newTask = await taskRepository.createTask({ ...taskData, folderId }, tx); diff --git a/src/swagger/swagger.yml b/src/swagger/swagger.yml index 1a36a17..b24cb2f 100644 --- a/src/swagger/swagger.yml +++ b/src/swagger/swagger.yml @@ -1092,14 +1092,14 @@ paths: type: integer example: 13 '400': - description: 잘못된 요청 (필수 값 누락 또는 형식 오류) + description: 잘못된 요청 (필수 값 누락 또는 형식 오류, 팀 과제 폴더 규칙 위반 등) content: application/json: example: resultType: "FAIL" code: 400 - errorCode: "BAD_REQUEST" - reason: "과제명은 필수입니다." + errorCode: "BAD_REQUEST | TEAM_FOLDER_REQUIRED" + reason: "과제명은 필수입니다. | 팀 과제는 '팀' 폴더에만 생성할 수 있습니다." data: null '401': description: 인증 실패 (토큰 만료 또는 누락) @@ -2810,6 +2810,7 @@ paths: - INVALID_FOLDER_TITLE: 폴더 이름이 길 때. - INVALID_COLOR: 색상 형식이 올바르지 않음. - DUPLICATE_COLOR: 이미 사용 중인 색상으로 변경 시도. + - PROTECTED_FOLDER: '팀' 폴더를 수정하려고 시도함. content: application/json: schema: @@ -2817,8 +2818,8 @@ paths: example: resultType: "FAIL" code: 400 - errorCode: "BAD_REQUEST | INVALID_FOLDER_TITLE | INVALID_COLOR | DUPLICATE_COLOR" - reason: "인증 토큰이 형식에 맞지 않습니다. | 폴더 이름은 최대 11자까지만 가능합니다. | 색상 형식이 올바르지 않습니다. | 이미 사용 중인 색상입니다." + errorCode: "BAD_REQUEST | INVALID_FOLDER_TITLE | INVALID_COLOR | DUPLICATE_COLOR | PROTECTED_FOLDER" + reason: "인증 토큰이 형식에 맞지 않습니다. | 폴더 이름은 최대 11자까지만 가능합니다. | 색상 형식이 올바르지 않습니다. | 이미 사용 중인 색상입니다. | '팀' 폴더는 수정할 수 없습니다." data: null '401': description: | @@ -2906,6 +2907,18 @@ paths: type: object nullable: true example: null + '400': + description: 잘못된 요청 (보호된 폴더 삭제 시도) + content: + application/json: + schema: + type: object + example: + resultType: "FAIL" + code: 400 + errorCode: "PROTECTED_FOLDER" + reason: "'팀' 폴더는 삭제할 수 없습니다." + data: null '401': description: | - TOKEN_NOT_FOUND: 인증 토큰이 없음. From e683be08ff65f78ff896741b24c180d373ec992a Mon Sep 17 00:00:00 2001 From: Sienna <156987557+jeongkyueun@users.noreply.github.com> Date: Thu, 12 Feb 2026 21:01:08 +0900 Subject: [PATCH 2/4] Add API endpoints for OAuth, Task, Alarm, User, and Reference --- README.md | 60 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 39 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 0f8dca8..7c618fe 100644 --- a/README.md +++ b/README.md @@ -174,25 +174,43 @@ npm start
## 📝 주요 API 목록 + +### 🔐 OAuth Domain +| 기능명 | Method | Endpoint | 설명 | +| :--- | :---: | :--- | :--- | +| 카카오 로그인 | GET | `/api/v1/auth/kakao` | 카카오 OAuth 로그인 요청 | +| Access Token 재발급 | POST | `/api/v1/auth/refresh` | Refresh Token 기반 Access Token 재발급 | + +### 📋 Task Domain +| 기능명 | Method | Endpoint | 설명 | +| :--- | :---: | :--- | :--- | +| 과제 생성 | POST | `/api/v1/task` | 개인/팀 과제 생성 | +| 과제 목록 조회 | GET | `/api/v1/task` | 필터링(개인/팀/마감일/진척도 등) 기반 과제 조회 | +| 과제 상세조회 | GET | `/api/v1/task/{taskId}` | 과제 + 세부과제 + 자료 + 커뮤니케이션 통합 조회 | +| 과제 수정 | PATCH | `/api/v1/task/{taskId}` | 과제 정보 수정 | +| 과제 삭제 | DELETE | `/api/v1/task/{taskId}` | 과제 삭제 | +| 세부 과제 상태 변경 | PATCH | `/api/v1/task/subtask/{subTaskId}/status` | 세부과제 완료/진행 상태 변경 | +| 팀원 초대 링크 생성 | POST | `/api/v1/task/{taskId}/invitation` | 팀 과제 초대 URL 생성 | + +### 🔔 Alarm Domain +| 기능명 | Method | Endpoint | 설명 | +| :--- | :---: | :--- | :--- | +| 알림 목록 조회 | GET | `/api/v1/alarm` | 현재 사용자 알림 목록 조회 | +| 알림 읽기 처리 | PATCH | `/api/v1/alarm/{alarmId}` | 특정 알림 읽음 처리 | +| 알림 전체 삭제 | DELETE | `/api/v1/alarm/all` | 모든 알림 삭제 | +| 알림 설정 변경 | PATCH | `/api/v1/alarm/settings/task` | 사용자 알림 설정 변경 | + +### 👤 User Domain +| 기능명 | Method | Endpoint | 설명 | +| :--- | :---: | :--- | :--- | +| 내 정보 조회 | GET | `/api/v1/user/me` | 로그인 사용자 정보 조회 | +| 프로필 수정 | PATCH | `/api/v1/user/profile` | 닉네임 등 사용자 정보 수정 | +| 폴더 생성 | POST | `/api/v1/user/folder` | 사용자 폴더 생성 | +| 폴더 조회 | GET | `/api/v1/user/folder` | 사용자 폴더 목록 조회 | + +### 📂 Reference Domain | 기능명 | Method | Endpoint | 설명 | -| --- | --- | --- | --- | -| 카카오 로그인 | GET | /api/v1/auth/kakao | 카카오 OAuth 로그인 요청 | -| Access Token 재발급 | POST | /api/v1/auth/refresh | Refresh Token 기반 Access Token 재발급 | -| 과제 생성 | POST | /api/v1/task | 개인/팀 과제 생성 | -| 과제 목록 조회 | GET | /api/v1/task | 필터링(개인/팀/마감일/진척도 등) 기반 과제 조회 | -| 과제 상세조회 | GET | /api/v1/task/{taskId} | 과제 + 세부과제 + 자료 + 커뮤니케이션 통합 조회 | -| 과제 수정 | PATCH | /api/v1/task/{taskId} | 과제 정보 수정 | -| 과제 삭제 | DELETE | /api/v1/task/{taskId} | 과제 삭제 | -| 세부 과제 상태 변경 | PATCH | /api/v1/task/subtask/{subTaskId}/status | 세부과제 완료/진행 상태 변경 | -| 팀원 초대 링크 생성 | POST | /api/v1/task/{taskId}/invitation | 팀 과제 초대 URL 생성 | -| 알림 목록 조회 | GET | /api/v1/alarm | 현재 사용자 알림 목록 조회 | -| 알림 읽기 처리 | PATCH | /api/v1/alarm/{alarmId} | 특정 알림 읽음 처리 | -| 알림 전체 삭제 | DELETE | /api/v1/alarm/all | 모든 알림 삭제 | -| 알림 설정 변경 | PATCH | /api/v1/alarm/settings/task | 사용자 알림 설정 변경 | -| 내 정보 조회 | GET | /api/v1/user/me | 로그인 사용자 정보 조회 | -| 프로필 수정 | PATCH | /api/v1/user/profile | 닉네임 등 사용자 정보 수정 | -| 폴더 생성 | POST | /api/v1/user/folder | 사용자 폴더 생성 | -| 폴더 조회 | GET | /api/v1/user/folder | 사용자 폴더 목록 조회 | -| 자료 추가 | POST | /api/v1/reference/data/{taskId} | URL 또는 파일 자료 업로드 | -| 커뮤니케이션 생성 | POST | /api/v1/reference/communication/{taskId} | 과제별 커뮤니케이션 생성 | -| 회의록 생성 | POST | /api/v1/reference/log/{taskId} | 과제 회의록 작성 | +| :--- | :---: | :--- | :--- | +| 자료 추가 | POST | `/api/v1/reference/data/{taskId}` | URL 또는 파일 자료 업로드 | +| 커뮤니케이션 생성 | POST | `/api/v1/reference/communication/{taskId}` | 과제별 커뮤니케이션 생성 | +| 회의록 생성 | POST | `/api/v1/reference/log/{taskId}` | 과제 회의록 작성 | From 758494adff3b241a1dcb0e06279b78cb11a0e7f7 Mon Sep 17 00:00:00 2001 From: Sienna <156987557+jeongkyueun@users.noreply.github.com> Date: Thu, 12 Feb 2026 21:04:43 +0900 Subject: [PATCH 3/4] Delete test-socket.js --- test-socket.js | 62 -------------------------------------------------- 1 file changed, 62 deletions(-) delete mode 100644 test-socket.js diff --git a/test-socket.js b/test-socket.js deleted file mode 100644 index eb0e7c6..0000000 --- a/test-socket.js +++ /dev/null @@ -1,62 +0,0 @@ -import { io } from "socket.io-client"; -/* -const socket = io("http://localhost:8000", { - path: "/socket.io/" -}); - -socket.on("connect", () => { - console.log("✅ connected:", socket.id); -}); - -socket.on("disconnect", () => { - console.log("❌ disconnected"); -}); -*/ -//const io = require('socket.io-client'); -//import { io } from 'socket.io-client'; - - - - - - -const socket = io('http://localhost:8000', { - path: '/socket.io/', - transports: ['websocket'] -}); - -// 연결 이벤트 -socket.on('connect', () => { - console.log('✅ 연결 성공! Socket ID:', socket.id); - - // 테스트 방 참가 - socket.emit('joinTaskRoom', '1'); - - // 2초 후에 상태 업데이트 - setTimeout(() => { - console.log('\n🔄 서브태스크 상태 업데이트 시도...'); - socket.emit('updateSubtaskStatus', { - taskId: 2, - subTaskId: 2, - status: 'COMPLETED' - }, (response) => { - console.log('서버 응답:', response); - }); - }, 2000); -}); - -// 이벤트 수신 -socket.on('subtaskStatusUpdated', (data) => { - console.log('\n📩 상태 업데이트 수신:', data); -}); - -// 에러 처리 -socket.on('connect_error', (error) => { - console.error('연결 오류:', error); -}); - -// 종료 처리 -process.on('SIGINT', () => { - socket.disconnect(); - process.exit(); -}); \ No newline at end of file From 654646cf167e5204c8bb17ef70ceec3786dedb8a Mon Sep 17 00:00:00 2001 From: soyun0318 Date: Thu, 12 Feb 2026 21:13:07 +0900 Subject: [PATCH 4/4] =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EB=B0=8F=20=EB=A9=A4=EB=B2=84=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20api=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/task.controller.js | 53 ++++++++++++++++++++------- src/dtos/task.dto.js | 36 +++++++++++++----- src/routes/task.route.js | 7 +++- src/services/task.service.js | 11 ++++-- src/swagger/swagger.yml | 59 ++++++++++++++---------------- 5 files changed, 107 insertions(+), 59 deletions(-) diff --git a/src/controllers/task.controller.js b/src/controllers/task.controller.js index d4dee2c..671c487 100644 --- a/src/controllers/task.controller.js +++ b/src/controllers/task.controller.js @@ -1,4 +1,5 @@ import taskService from "../services/task.service.js"; +import { uploadToS3 } from '../middlewares/upload.middleware.js'; import { TaskRequestDTO, TaskResponseDTO } from "../dtos/task.dto.js"; import { BadRequestError } from "../errors/custom.error.js"; @@ -40,21 +41,46 @@ class TaskController { // 과제 수정 async updateTask(req, res, next) { - try { - const { taskId } = req.params; - const taskRequest = TaskRequestDTO.toUpdate(req.body); - - const result = await taskService.modifyTask(parseInt(taskId), taskRequest); + try { + const { taskId } = req.params; + + let customFileNames = []; + if (req.body.fileNames) { + const rawNames = req.body.fileNames; + if (typeof rawNames === 'string' && rawNames.startsWith('[')) { + customFileNames = JSON.parse(rawNames); + } else if (typeof rawNames === 'string') { + customFileNames = rawNames.split(',').map(name => name.trim()); + } else { + customFileNames = rawNames; + } + } - res.status(200).json({ - resultType: "SUCCESS", - message: "요청이 성공적으로 처리되었습니다.", - data: result - }); - } catch (error) { - next(error); + let fileReferences = []; + if (req.files && req.files.length > 0) { + for (let i = 0; i < req.files.length; i++) { + const file = req.files[i]; + const fileUrl = await uploadToS3(file); + + fileReferences.push({ + name: (customFileNames && customFileNames[i]) ? customFileNames[i] : file.originalname, + fileUrl: fileUrl + }); + } } + + const taskRequest = TaskRequestDTO.toUpdate(req.body, fileReferences); + const result = await taskService.modifyTask(parseInt(taskId), taskRequest); + + res.status(200).json({ + resultType: "SUCCESS", + message: "과제가 성공적으로 수정되었습니다.", + data: result + }); + } catch (error) { + next(error); } +} // 과제 삭제 async deleteTask(req, res, next) { @@ -163,7 +189,8 @@ class TaskController { // 팀원 정보 수정 (역할 변경) async updateTeamMember(req, res, next) { try { - const { taskId, memberId } = req.params; + const taskId = req.body.taskId || req.params.taskId; + const memberId = req.body.memberId || req.params.memberId; const { role } = req.body; const result = await taskService.modifyMemberRole( diff --git a/src/dtos/task.dto.js b/src/dtos/task.dto.js index 208a366..d5f774d 100644 --- a/src/dtos/task.dto.js +++ b/src/dtos/task.dto.js @@ -59,18 +59,34 @@ export class TaskRequestDTO { } // 과제 수정 - static toUpdate(data) { + static toUpdate(data, uploadedFiles = []) { + const subTasks = typeof data.subTasks === 'string' ? JSON.parse(data.subTasks) : (data.subTasks || []); + const existingRefs = typeof data.references === 'string' ? JSON.parse(data.references) : (data.references || []); + return { title: data.title, - folderId: data.folderId, - deadline: data.deadline ? new Date(data.deadline) : undefined, - type: data.type === "TEAM" ? "TEAM" : (data.type === "PERSONAL" ? "PERSONAL" : undefined), - subTasks: (data.subTasks || []).map(st => ({ - title: st.title, - endDate: st.endDate ? new Date(st.endDate) : new Date(), - status: st.status || "PENDING" - })), - references: data.references || [] + folderId: data.folderId ? Number(data.folderId) : undefined, + deadline: data.deadline, + type: (data.type === "TEAM" || data.type === "팀") ? "TEAM" : "PERSONAL", + subTasks: subTasks + .filter(st => st !== null && st !== undefined) // 빈 객체 제거 + .map(st => ({ + title: st.title || "제목 없음", + endDate: st.endDate ? new Date(st.endDate) : new Date(), + status: st.status || "PENDING" + })), + references: [ + ...existingRefs.map(ref => ({ + name: ref.name, + url: ref.url || null, + fileUrl: ref.fileUrl || ref.file_url || null + })), + ...uploadedFiles.map(file => ({ + name: file.name, + url: null, + fileUrl: file.fileUrl || file.file_url + })) + ] }; } } diff --git a/src/routes/task.route.js b/src/routes/task.route.js index 8df3e53..ac87a9f 100644 --- a/src/routes/task.route.js +++ b/src/routes/task.route.js @@ -1,7 +1,12 @@ import express from "express"; +import multer from "multer"; + import taskController from "../controllers/task.controller.js"; import authenticate from "../middlewares/authenticate.middleware.js"; +const upload = multer({ storage: multer.memoryStorage() }); + + const router = express.Router(); // 완료된 과제 @@ -14,7 +19,7 @@ router.post("/", authenticate, taskController.createTask); router.patch("/priority", authenticate, taskController.updateTaskPriorities); // PATCH /api/v1/task/:taskId -- 과제 수정 -router.patch("/:taskId", authenticate, taskController.updateTask); +router.patch("/:taskId", authenticate, upload.array('files'), taskController.updateTask); // DELETE /api/v1/task/:taskId -- 과제 삭제 router.delete("/:taskId", authenticate, taskController.deleteTask); diff --git a/src/services/task.service.js b/src/services/task.service.js index a323547..09a728e 100644 --- a/src/services/task.service.js +++ b/src/services/task.service.js @@ -139,7 +139,7 @@ class TaskService { } // 과제 수정 - async modifyTask(taskId, data) { + async modifyTask(taskId, data = {}) { const { subTasks, references, folderId, ...taskData } = data; // 과제 존재 여부 확인 @@ -205,9 +205,12 @@ class TaskService { } // 자료 갱신 - await taskRepository.deleteAllReferences(taskId, tx); - if (references?.length > 0) { - await taskRepository.addReferences(taskId, references, tx); + if (references) { + await taskRepository.deleteAllReferences(taskId, tx); + + if (references.length > 0) { + await taskRepository.addReferences(taskId, references, tx); + } } return { taskId: updatedTask.id }; diff --git a/src/swagger/swagger.yml b/src/swagger/swagger.yml index 1a36a17..f38b1a5 100644 --- a/src/swagger/swagger.yml +++ b/src/swagger/swagger.yml @@ -1302,7 +1302,11 @@ paths: tags: - Task summary: 과제 수정 - description: 과제의 기본 정보 및 세부 태스크를 수정합니다. + description: | + 과제 정보와 여러 개의 파일을 수정합니다. + - **files**: 여러 개의 파일을 한꺼번에 선택할 수 있습니다. + - **fileNames**: 업로드한 파일 순서대로 이름을 쉽표(,)로 구분해서 적어주세요. + (예: `보고서최종, 참고자료1`) security: - bearerAuth: [] parameters: @@ -1314,7 +1318,7 @@ paths: requestBody: required: true content: - application/json: + multipart/form-data: schema: type: object properties: @@ -1337,37 +1341,23 @@ paths: type: integer example: 2 subTasks: - type: array - items: - type: object - properties: - title: - type: string - example: "세부과제 1" - endDate: - type: string - format: date - example: "2026-05-10" - status: - type: string - example: "COMPLETED" - isAlarm: - type: boolean - example: true - assigneeId: - type: integer - example: 5 + type: string + description: "세부 과제 배열 (JSON string)" + example: '[{"title": "세부과제 1", "endDate": "2026-05-10", "status": "COMPLETED", "isAlarm": true, "assigneeId": 5}]' references: - type: array + type: string + description: "기존 유지할 자료 목록 (JSON string)" + example: '[{"name": "자료1", "url": "https://mariadb.org/documentation/"}]' + fileNames: + type: string + description: "파일 원본 이름 대신 저장할 이름 (쉼표 구분)" + example: "과제발표자료, 참고도면" + files: + type: array items: - type: object - properties: - name: - type: string - example: "자료1" - url: - type: string - example: "https://mariadb.org/documentation/" + type: string + format: binary + description: "업로드할 파일들을 모두 선택하세요." responses: '200': description: 과제 수정 성공 @@ -1764,7 +1754,14 @@ paths: application/json: schema: type: object + required: [taskId, memberId, role] properties: + taskId: + type: integer + example: 1 + memberId: + type: integer + example: 5 role: type: integer description: "0: 일반, 1: 관리자"