From f242064024302ca81b20ef3b05d9fd0cd5b093e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=86=8C=EC=9C=A4?= Date: Mon, 19 Jan 2026 12:22:47 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B3=BC=EC=A0=9C=20=EC=A1=B0=ED=9A=8C=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 11 +- src/controllers/task.controller.js | 199 ++++++++++++++++------- src/dtos/task.dto.js | 95 +++++++++++ src/repositories/task.repository.js | 95 +++++++++++ src/routes/task.route.js | 19 ++- src/services/task.service.js | 234 ++++++++++++++++++++-------- 6 files changed, 529 insertions(+), 124 deletions(-) create mode 100644 src/dtos/task.dto.js create mode 100644 src/repositories/task.repository.js diff --git a/package-lock.json b/package-lock.json index e36d34f..da3c8b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1021,7 +1021,8 @@ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.2.tgz", "integrity": "sha512-zfWWa+V2ViDCY/cmUfRqeWY1yLto+EpxjXnZzenB1TyxsTiXaTWeZFIZw6mac52BsuQm0RjCnisjBtdBaXOI6w==", "devOptional": true, - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/@electric-sql/pglite-socket": { "version": "0.0.6", @@ -2638,8 +2639,7 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "devOptional": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dayjs": { "version": "1.11.19", @@ -3158,6 +3158,7 @@ "integrity": "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g==", "devOptional": true, "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -3961,6 +3962,7 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/config": "7.2.0", "@prisma/dev": "0.17.0", @@ -4220,8 +4222,7 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "devOptional": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/semver": { "version": "7.7.3", diff --git a/src/controllers/task.controller.js b/src/controllers/task.controller.js index fbb1f1c..9f1bfed 100644 --- a/src/controllers/task.controller.js +++ b/src/controllers/task.controller.js @@ -1,56 +1,149 @@ -import { taskService } from '../services/task.service.js'; - -// 세부 TASK 완료 처리 API -const updateSubTaskStatus = async (req, res, next) => { - try { - const { subTaskId } = req.params; - const { status } = req.body; - - // 서비스 계층 호출 - const updatedTask = await taskService.updateSubTaskStatus(subTaskId, status); - - // 응답 형식에 맞게 데이터 가공 - const responseData = { - resultType: 'SUCCESS', - message: '태스크 상태가 업데이트되었습니다.', - data: { - sub_task_id: updatedTask.id, - status: status === 'COMPLETE' ? '완료' : '미완료' - } - }; - - return res.status(200).json(responseData); - } catch (error) { - next(error); +import taskService from "../services/task.service.js"; +import { createTaskRequestDTO } from "../dtos/task.dto.js"; +import { updateTaskRequestDTO } from "../dtos/task.dto.js"; +import { taskDetailResponseDTO } from "../dtos/task.dto.js"; +import { taskListResponseDTO } from "../dtos/task.dto.js"; + +class TaskController { + // 과제 생성 + async createTask(req, res, next) { + try { + const taskRequest = createTaskRequestDTO(req.body); + + const result = await taskService.registerTask(taskRequest); + + res.status(201).json({ + resultType: "SUCCESS", + message: "요청이 처리되어서 새로운 과제가 생성되었습니다.", + data: result + }); + } catch (error) { + next(error); + } + } + + // 과제 수정 + async updateTask(req, res, next) { + try { + const { taskId } = req.params; + const taskRequest = updateTaskRequestDTO(req.body); + + 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) { + try { + const { taskId } = req.params; + await taskService.removeTask(parseInt(taskId)); + + res.status(200).json({ + resultType: "SUCCESS", + message: "과제가 성공적으로 삭제되었습니다.", + data: null + }); + } catch (error) { + next(error); + } + } + + // 과제 세부 사항 조회 + async getTaskDetail(req, res, next) { + try { + const { taskId } = req.params; + const task = await taskService.getTaskDetail(parseInt(taskId)); + + res.status(200).json({ + resultType: "SUCCESS", + message: "서버가 요청을 성공적으로 처리하였습니다.", + data: taskDetailResponseDTO(task) + }); + } catch (error) { + next(error); + } } -}; - -// 세부task 날짜 변경 API -const updateSubTaskDeadline = async (req, res, next) => { - try { - const { subTaskId } = req.params; - const { endDate } = req.body; - - // 서비스 계층 호출 - const updatedTask = await taskService.updateSubTaskDeadline(subTaskId, endDate); - - // 응답 형식에 맞게 데이터 가공 - const responseData = { - resultType: 'SUCCESS', - message: '마감 기한이 변경되었습니다.', - data: { - sub_task_id: updatedTask.id, - end_date: updatedTask.endDate.toISOString().split('T')[0] - } - }; - - return res.status(200).json(responseData); - } catch (error) { - next(error); + + // 과제 목록 조회 + async getTasks(req, res, next) { + try { + console.log("실제 들어온 쿼리:", req.query); + + const queryParams = { + type: req.query.type, + sort: req.query.sort, + folderId: req.query.folderId || req.query.folder_id || req.query.folderld, + }; + + const tasks = await taskService.getTaskList(queryParams); + + res.status(200).json({ + resultType: "SUCCESS", + message: "서버가 요청을 성공적으로 처리하였습니다.", + data: taskListResponseDTO(tasks) + }); + } catch (error) { + next(error); + } + } + + // 세부 TASK 완료 처리 API + async updateSubTaskStatus(req, res, next) { + try { + const { subTaskId } = req.params; + const { status } = req.body; + + // 서비스 계층 호출 + const updatedTask = await taskService.updateSubTaskStatus(subTaskId, status); + + // 응답 형식에 맞게 데이터 가공 + const responseData = { + resultType: 'SUCCESS', + message: '태스크 상태가 업데이트되었습니다.', + data: { + sub_task_id: updatedTask.id, + status: status === 'COMPLETE' ? '완료' : '미완료' + } + }; + + return res.status(200).json(responseData); + } catch (error) { + next(error); + } + } + + // 세부task 날짜 변경 API + async updateSubTaskDeadline(req, res, next){ + try { + const { subTaskId } = req.params; + const { endDate } = req.body; + + // 서비스 계층 호출 + const updatedTask = await taskService.updateSubTaskDeadline(subTaskId, endDate); + + // 응답 형식에 맞게 데이터 가공 + const responseData = { + resultType: 'SUCCESS', + message: '마감 기한이 변경되었습니다.', + data: { + sub_task_id: updatedTask.id, + end_date: updatedTask.endDate.toISOString().split('T')[0] + } + }; + + return res.status(200).json(responseData); + } catch (error) { + next(error); + } } -}; +} -export const taskController = { - updateSubTaskStatus, - updateSubTaskDeadline, -}; +export default new TaskController(); \ No newline at end of file diff --git a/src/dtos/task.dto.js b/src/dtos/task.dto.js new file mode 100644 index 0000000..313ac8c --- /dev/null +++ b/src/dtos/task.dto.js @@ -0,0 +1,95 @@ +export const createTaskRequestDTO = (data) => { + return { + title: data.title, + folderId: data.folderId, + deadline: new Date(data.deadline), + type: data.type === "팀" ? "TEAM" : "PERSONAL", + status: "PENDING", + subTasks: (data.subTasks || []).map(st => ({ + title: st.title, + endDate: new Date(st.deadline) + })), + references: data.references || [] + }; +}; + +export const updateTaskRequestDTO = (data) => { + return { + title: data.title, + folderId: data.folderId, + deadline: data.deadline ? new Date(data.deadline) : undefined, + type: data.type === "팀" ? "TEAM" : (data.type === "개인" ? "PERSONAL" : undefined), + subTasks: (data.subTasks || []).map(st => ({ + title: st.title, + endDate: new Date(st.deadline), + status: st.status || "PENDING" + })), + references: data.references || [] + }; +}; + +export const taskDetailResponseDTO = (task) => { + // D-Day 계산 + const today = new Date(); + const deadlineDate = new Date(task.deadline); + const diffTime = deadlineDate - today; + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + const dDay = diffDays === 0 ? "D-Day" : diffDays > 0 ? `D-${diffDays}` : `D+${Math.abs(diffDays)}`; + + // 진행률 계산 (완료된 세부 과제 수 / 전체 세부 과제 수) + const totalSubTasks = task.subTasks?.length || 0; + const completedSubTasks = task.subTasks?.filter(st => st.status === 'COMPLETED').length || 0; + const progressRate = totalSubTasks > 0 ? Math.round((completedSubTasks / totalSubTasks) * 100) : 0; + + return { + taskId: task.id, + title: task.title, + type: task.type === "TEAM" ? "팀" : "개인", + deadline: task.deadline.toISOString().split('T')[0], + dDay: dDay, + progressRate: progressRate, + subTasks: task.subTasks?.map(st => ({ + subTaskId: st.id, + title: st.title, + deadline: st.endDate?.toISOString().split('T')[0] || null, + status: st.status === 'COMPLETED' ? '완료' : '진행중', + isAlarm: st.isAlarm || false, + commentCount: st._count?.comments || 0, + assigneeName: st.assigneeName || "미지정" + })) || [], + communications: task.communications?.map(c => ({ + name: c.name, + url: c.url + })) || [], + meetingLogs: task.logs?.map(log => ({ + logId: log.id, + title: log.title + })) || [], + references: task.references?.map(r => ({ + name: r.name, + url: r.url + })) || [] + }; +}; + +export const taskListResponseDTO = (tasks) => { + return tasks.map(task => { + // D-Day 계산 + const today = new Date(); + const deadlineDate = new Date(task.deadline); + const diffTime = deadlineDate - today; + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + const dDay = diffDays === 0 ? "D-Day" : diffDays > 0 ? `D-${diffDays}` : `D+${Math.abs(diffDays)}`; + + return { + taskId: task.id, + folderId: task.folderId, + folderTitle: task.folder?.title || "미지정", + title: task.title, + type: task.type === "TEAM" ? "팀" : "개인", + deadline: task.deadline.toISOString().split('T')[0].replace(/-/g, '.'), + dDay: dDay, + progressRate: task.progressRate // 서비스에서 계산된 값 사용 + }; + }); +}; \ No newline at end of file diff --git a/src/repositories/task.repository.js b/src/repositories/task.repository.js new file mode 100644 index 0000000..d9898f2 --- /dev/null +++ b/src/repositories/task.repository.js @@ -0,0 +1,95 @@ +import { prisma } from "../db.config.js"; + +class TaskRepository { + // 폴더 찾기 + async findFolderById(id) { + return await prisma.folder.findUnique({ where: { id } }); + } + + // 과제 찾기 + async findTaskById(id) { + return await prisma.task.findUnique({ where: { id } }); + } + + // 과제 생성 + async createTask(data, tx) { + return await tx.task.create({ data }); + } + + // 과제 수정 + async updateTask(id, data, tx) { + return await tx.task.update({ where: { id }, data }); + } + + // 과제 세부 사항 조회 + async findTaskDetail(id) { + return await prisma.task.findUnique({ + where: { id }, + nclude: { + subTasks: { + include: { + _count: { + select: { comments: true } + } + } + }, + references: true, + logs: true, + communications: true + } + }); + } + + // 과제 목록 조회 + async findAllTasks({ type, folderId, sort }) { + const query = { + where: {}, + include: { + folder: true, + subTasks: true + } + }; + + if (type) query.where.type = type === "팀" ? "TEAM" : "INDIVIDUAL"; + if (folderId) query.where.folderId = parseInt(folderId); + + if (sort === '마감일순' || !sort) { + query.orderBy = { deadline: 'asc' }; + } + + return await prisma.task.findMany(query); +} + + // 세부 과제 일괄 삭제 + async deleteAllSubTasks(taskId, tx) { + return await tx.subTask.deleteMany({ where: { taskId } }); + } + + // 자료 일괄 삭제 + async deleteAllReferences(taskId, tx) { + return await tx.reference.deleteMany({ where: { taskId } }); + } + + // 세부 과제 추가 + async addSubTasks(taskId, subTasks, tx) { + return await tx.subTask.createMany({ + data: subTasks.map(st => ({ ...st, taskId })) + }); + } + + // 자료 추가 + async addReferences(taskId, refs, tx) { + return await tx.reference.createMany({ + data: refs.map(r => ({ ...r, taskId })) + }); + } + + // 과제 삭제 + async deleteTask(id) { + return await prisma.task.delete({ + where: { id } + }); + } +} + +export default new TaskRepository(); diff --git a/src/routes/task.route.js b/src/routes/task.route.js index 2c1a41e..2b1c32e 100644 --- a/src/routes/task.route.js +++ b/src/routes/task.route.js @@ -1,8 +1,23 @@ -import express from 'express'; -import { taskController } from '../controllers/task.controller.js'; +import express from "express"; +import taskController from "../controllers/task.controller.js"; const router = express.Router(); +// POST /api/v1/task -- 과제 생성 +router.post("/", taskController.createTask); + +// PATCH /api/v1/task/:taskId -- 과제 수정 +router.patch("/:taskId", taskController.updateTask); + +// DELETE /api/v1/task/:taskId -- 과제 삭제 +router.delete("/:taskId", taskController.deleteTask); + +// GET /api/v1/task/:taskId -- 과제 세부 사항 조회 +router.get("/:taskId", taskController.getTaskDetail); + +// GET /api/v1/task?sort=우선순위 -- 과제 목록 조회 +router.get("/", taskController.getTasks); + // 세부 TASK 완료 처리 API // 세부 TASK 상태 업데이트 router.patch( diff --git a/src/services/task.service.js b/src/services/task.service.js index 812d89d..4172c95 100644 --- a/src/services/task.service.js +++ b/src/services/task.service.js @@ -1,84 +1,190 @@ -import { prisma } from '../db.config.js'; +import taskRepository from "../repositories/task.repository.js"; +import { BadRequestError, NotFoundError } from "../errors/custom.error.js"; +import { prisma } from "../db.config.js"; -// 세부 TASK 완료 처리 API +class TaskService { + // 과제 등록 + async registerTask(data) { + const { subTasks, references, folderId, ...taskData } = data; -const updateSubTaskStatus = async (subTaskId, status) => { - try { - // 서브태스크 존재 여부 확인 - const existingTask = await prisma.SubTask.findUnique({ - where: { id: parseInt(subTaskId) }, + if (!taskData.title) throw new BadRequestError("과제명은 필수입니다."); + + const folder = await taskRepository.findFolderById(folderId); + if (!folder) throw new NotFoundError("존재하지 않는 폴더입니다."); + + return await prisma.$transaction(async (tx) => { + // 과제 생성 + const newTask = await taskRepository.createTask({ ...taskData, folderId }, tx); + + // 하위 데이터 저장 + if (subTasks.length > 0) { + await taskRepository.addSubTasks(newTask.id, subTasks, tx); + } + if (references.length > 0) { + await taskRepository.addReferences(newTask.id, references, tx); + } + + return { taskId: newTask.id }; }); + } - if (!existingTask) { - const error = new Error('해당하는 세부 태스크를 찾을 수 없습니다.'); - error.status = 404; - throw error; + // 과제 수정 + async modifyTask(taskId, data) { + const { subTasks, references, folderId, ...taskData } = data; + + // 과제 존재 여부 확인 + const currentTask = await taskRepository.findTaskById(taskId); + if (!currentTask) throw new NotFoundError("수정하려는 과제가 존재하지 않습니다."); + + // 폴더 변경 시 유효성 체크 + if (folderId) { + const folder = await taskRepository.findFolderById(folderId); + if (!folder) throw new NotFoundError("변경하려는 폴더가 존재하지 않습니다."); } - // 상태 업데이트(프리지마 모델명은 대소문자 구분!) - const updatedTask = await prisma.SubTask.update({ - where: { id: parseInt(subTaskId) }, - data: { - status: status === 'COMPLETE' ? 'COMPLETED' : 'PENDING', - updatedAt: new Date() - }, - }); + // 트랜잭션 + return await prisma.$transaction(async (tx) => { + // 과제 기본 정보 업데이트 + const updatedTask = await taskRepository.updateTask(taskId, { ...taskData, folderId }, tx); - return updatedTask; - } catch (error) { - console.error('Error updating subtask status:', error); - throw error; - } -}; - -// 세부task 날짜 변경 API -const updateSubTaskDeadline = async (subTaskId, deadline) => { - try { - // 서브태스크와 상위 태스크 정보 조회 - const existingTask = await prisma.SubTask.findUnique({ - where: { id: parseInt(subTaskId) }, - include: { - task: { - select: { - deadline: true - } - } + // 세부 과제 갱신 + await taskRepository.deleteAllSubTasks(taskId, tx); + if (subTasks?.length > 0) { + await taskRepository.addSubTasks(taskId, subTasks, tx); } + + // 자료 갱신 + await taskRepository.deleteAllReferences(taskId, tx); + if (references?.length > 0) { + await taskRepository.addReferences(taskId, references, tx); + } + + return { taskId: updatedTask.id }; }); + } - if (!existingTask) { - const error = new Error('해당하는 세부 태스크를 찾을 수 없습니다.'); - error.status = 404; - throw error; + // 과제 삭제 + async removeTask(taskId) { + // 과제 존재 여부 확인 + const currentTask = await taskRepository.findTaskById(taskId); + if (!currentTask) { + throw new NotFoundError("삭제하려는 과제가 존재하지 않습니다."); } - const newDeadline = new Date(deadline); - const parentEndDate = new Date(existingTask.task.deadline); + // 과제 삭제 실행 + return await taskRepository.deleteTask(taskId); + } + + // 세부 TASK 완료 처리 API + async updateSubTaskStatus(subTaskId, status) { + try { + // 서브태스크 존재 여부 확인 + const existingTask = await prisma.SubTask.findUnique({ + where: { id: parseInt(subTaskId) }, + }); + + if (!existingTask) { + const error = new Error('해당하는 세부 태스크를 찾을 수 없습니다.'); + error.status = 404; + throw error; + } + + // 상태 업데이트(프리지마 모델명은 대소문자 구분!) + const updatedTask = await prisma.SubTask.update({ + where: { id: parseInt(subTaskId) }, + data: { + status: status === 'COMPLETE' ? 'COMPLETED' : 'PENDING', + updatedAt: new Date() + }, + }); - // 부모 태스크의 마감일을 초과하는지 확인 - if (newDeadline > parentEndDate) { - const error = new Error('부모 Task의 마감일을 초과할 수 없습니다.'); - error.status = 400; + return updatedTask; + } catch (error) { + console.error('Error updating subtask status:', error); throw error; } + } + + async getTaskDetail(taskId) { + const task = await taskRepository.findTaskDetail(taskId); + + if (!task) { + throw new NotFoundError("과제를 찾을 수 없음"); + } + + return task; + } - // 마감일 업데이트 - const updatedTask = await prisma.SubTask.update({ - where: { id: parseInt(subTaskId) }, - data: { - endDate: newDeadline, - updatedAt: new Date() - }, + async getTaskList(queryParams) { + const tasks = await taskRepository.findAllTasks(queryParams); + + // 각 과제의 진행률을 미리 계산하여 객체에 추가 + const tasksWithProgress = tasks.map(task => { + const totalSubTasks = task.subTasks?.length || 0; + const completedSubTasks = task.subTasks?.filter( + st => st.status === 'COMPLETED' || st.status === '완료' + ).length || 0; + + const progressRate = totalSubTasks > 0 + ? Math.round((completedSubTasks / totalSubTasks) * 100) + : 0; + + return { ...task, progressRate }; }); - return updatedTask; - } catch (error) { - console.error('Error updating subtask deadline:', error); - throw error; + if (queryParams.sort === '진척도순') { + tasksWithProgress.sort((a, b) => b.progressRate - a.progressRate); + } + + return tasksWithProgress; +} + + // 세부task 날짜 변경 API + async updateSubTaskDeadline(subTaskId, deadline) { + try { + // 서브태스크와 상위 태스크 정보 조회 + const existingTask = await prisma.SubTask.findUnique({ + where: { id: parseInt(subTaskId) }, + include: { + task: { + select: { + deadline: true + } + } + } + }); + + if (!existingTask) { + const error = new Error('해당하는 세부 태스크를 찾을 수 없습니다.'); + error.status = 404; + throw error; + } + + const newDeadline = new Date(deadline); + const parentEndDate = new Date(existingTask.task.deadline); + + // 부모 태스크의 마감일을 초과하는지 확인 + if (newDeadline > parentEndDate) { + const error = new Error('부모 Task의 마감일을 초과할 수 없습니다.'); + error.status = 400; + throw error; + } + + // 마감일 업데이트 + const updatedTask = await prisma.SubTask.update({ + where: { id: parseInt(subTaskId) }, + data: { + endDate: newDeadline, + updatedAt: new Date() + }, + }); + + return updatedTask; + } catch (error) { + console.error('Error updating subtask deadline:', error); + throw error; + } } -}; +} -export const taskService = { - updateSubTaskStatus, - updateSubTaskDeadline, -}; \ No newline at end of file +export default new TaskService(); \ No newline at end of file