diff --git a/backend/prisma/migrations/20260605130000_milestone_progress/migration.sql b/backend/prisma/migrations/20260605130000_milestone_progress/migration.sql new file mode 100644 index 0000000..855c4a2 --- /dev/null +++ b/backend/prisma/migrations/20260605130000_milestone_progress/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "milestones" ADD COLUMN IF NOT EXISTS "progress" INTEGER NOT NULL DEFAULT 0; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 9a10ec5..9aaf292 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -162,6 +162,7 @@ model Milestone { description String? startDate DateTime? dueDate DateTime? + progress Int @default(0) links String? // JSON: [{ "label": string, "url": string }] completedAt DateTime? order Int @default(0) diff --git a/backend/src/lib/resolveUser.ts b/backend/src/lib/resolveUser.ts new file mode 100644 index 0000000..842ae7c --- /dev/null +++ b/backend/src/lib/resolveUser.ts @@ -0,0 +1,23 @@ +import { prisma } from './prisma'; +import { AppError } from '../middleware/errorHandler'; + +/** task 작성자 또는 관리자 등 유효한 user id 반환 (FK 오류 방지) */ +export async function resolveTaskActorId(taskId: string): Promise { + const task = await prisma.task.findUnique({ + where: { id: taskId }, + select: { creatorId: true }, + }); + + if (task?.creatorId) { + const creator = await prisma.user.findUnique({ where: { id: task.creatorId } }); + if (creator) return creator.id; + } + + const admin = await prisma.user.findFirst({ where: { role: 'ADMIN' }, select: { id: true } }); + if (admin) return admin.id; + + const anyUser = await prisma.user.findFirst({ select: { id: true } }); + if (anyUser) return anyUser.id; + + throw new AppError(500, '사용자를 찾을 수 없습니다. 관리자 계정을 먼저 생성해 주세요.'); +} diff --git a/backend/src/routes/files.ts b/backend/src/routes/files.ts index 23e0bc7..89b0082 100644 --- a/backend/src/routes/files.ts +++ b/backend/src/routes/files.ts @@ -2,6 +2,7 @@ import { Router } from 'express'; import path from 'path'; import fs from 'fs'; import { prisma } from '../lib/prisma'; +import { resolveTaskActorId } from '../lib/resolveUser'; import { upload } from '../middleware/upload'; import { AppError } from '../middleware/errorHandler'; @@ -26,6 +27,8 @@ router.post('/upload/:taskId', upload.single('file'), async (req, res, next) => if (!milestone) throw new AppError(404, '단계를 찾을 수 없습니다.'); } + const uploadedBy = await resolveTaskActorId(taskId); + const fileRecord = await prisma.file.create({ data: { taskId, @@ -35,7 +38,7 @@ router.post('/upload/:taskId', upload.single('file'), async (req, res, next) => mimetype: req.file.mimetype, size: req.file.size, path: req.file.path, - uploadedBy: body.uploadedBy ?? 'system', + uploadedBy, }, }); diff --git a/backend/src/routes/milestones.ts b/backend/src/routes/milestones.ts index 1fa9f23..f433c78 100644 --- a/backend/src/routes/milestones.ts +++ b/backend/src/routes/milestones.ts @@ -1,31 +1,33 @@ import { Router } from 'express'; import { prisma } from '../lib/prisma'; +import { resolveTaskActorId } from '../lib/resolveUser'; import { AppError } from '../middleware/errorHandler'; const router = Router(); -async function resolveUpdatedBy(taskId: string): Promise { - const task = await prisma.task.findUnique({ where: { id: taskId }, select: { creatorId: true } }); - if (task?.creatorId) return task.creatorId; - const admin = await prisma.user.findFirst({ where: { role: 'ADMIN' }, select: { id: true } }); - if (!admin) throw new AppError(500, '피드백 작성자를 찾을 수 없습니다.'); - return admin.id; -} - function normalizeLinks(links: unknown): string | null { if (!links) return null; if (typeof links === 'string') { try { - JSON.parse(links); + const parsed = JSON.parse(links); + if (Array.isArray(parsed) && parsed.length === 0) return null; return links; } catch { return null; } } - if (Array.isArray(links)) return JSON.stringify(links); + if (Array.isArray(links)) { + return links.length ? JSON.stringify(links) : null; + } return null; } +function clampProgress(value: unknown): number { + const n = Number(value); + if (Number.isNaN(n)) return 0; + return Math.min(100, Math.max(0, Math.round(n))); +} + // GET /api/milestones/:taskId router.get('/:taskId', async (req, res, next) => { try { @@ -43,32 +45,33 @@ router.get('/:taskId', async (req, res, next) => { router.post('/:taskId', async (req, res, next) => { try { const taskId = req.params.taskId; - const { title, description, startDate, dueDate, feedback, links } = - req.body as Record; + const { title, description, startDate, dueDate, feedback, links, progress } = + req.body as Record; - if (!title?.trim()) throw new AppError(400, '단계 제목은 필수입니다.'); + if (!title?.toString().trim()) throw new AppError(400, '단계 제목은 필수입니다.'); const count = await prisma.milestone.count({ where: { taskId } }); const milestone = await prisma.milestone.create({ data: { taskId, - title: title.trim(), - description: description?.trim() || null, - startDate: startDate ? new Date(startDate) : null, - dueDate: dueDate ? new Date(dueDate) : null, + title: String(title).trim(), + description: description?.toString().trim() || null, + startDate: startDate ? new Date(String(startDate)) : null, + dueDate: dueDate ? new Date(String(dueDate)) : null, + progress: progress !== undefined ? clampProgress(progress) : 0, links: normalizeLinks(links), order: count, }, }); - if (feedback?.trim()) { - const updatedBy = await resolveUpdatedBy(taskId); + if (feedback?.toString().trim()) { + const updatedBy = await resolveTaskActorId(taskId); await prisma.taskDetail.create({ data: { taskId, milestoneId: milestone.id, - content: feedback.trim(), + content: feedback.toString().trim(), updatedBy, }, }); @@ -83,7 +86,7 @@ router.post('/:taskId', async (req, res, next) => { // PATCH /api/milestones/item/:id router.patch('/item/:id', async (req, res, next) => { try { - const { title, description, startDate, dueDate, feedback, links, completed, order } = + const { title, description, startDate, dueDate, feedback, links, progress, completed, order } = req.body as Record; const existing = await prisma.milestone.findUnique({ where: { id: req.params.id } }); @@ -94,18 +97,20 @@ router.patch('/item/:id', async (req, res, next) => { data: { ...(title !== undefined && { title: String(title).trim() }), ...(description !== undefined && { description: description ? String(description).trim() : null }), - ...(startDate !== undefined && { startDate: startDate ? new Date(startDate as string) : null }), - ...(dueDate !== undefined && { dueDate: dueDate ? new Date(dueDate as string) : null }), + ...(startDate !== undefined && { startDate: startDate ? new Date(String(startDate)) : null }), + ...(dueDate !== undefined && { dueDate: dueDate ? new Date(String(dueDate)) : null }), + ...(progress !== undefined && { progress: clampProgress(progress) }), ...(links !== undefined && { links: normalizeLinks(links) }), ...(order !== undefined && { order: Number(order) }), ...(completed !== undefined && { completedAt: completed ? new Date() : null, + ...(completed && { progress: 100 }), }), }, }); if (typeof feedback === 'string' && feedback.trim()) { - const updatedBy = await resolveUpdatedBy(existing.taskId); + const updatedBy = await resolveTaskActorId(existing.taskId); await prisma.taskDetail.create({ data: { taskId: existing.taskId, diff --git a/frontend/src/components/detail/StageModal.tsx b/frontend/src/components/detail/StageModal.tsx index eacc067..deeb1b9 100644 --- a/frontend/src/components/detail/StageModal.tsx +++ b/frontend/src/components/detail/StageModal.tsx @@ -6,6 +6,7 @@ export interface StageFormData { title: string; startDate: string; dueDate: string; + progress: number; description: string; feedback: string; links: MilestoneLink[]; @@ -39,6 +40,7 @@ export function StageModal({ mode, milestone, onSave, onClose, saving }: StageMo title: milestone?.title ?? '', startDate: toDateInput(milestone?.startDate), dueDate: toDateInput(milestone?.dueDate), + progress: milestone?.progress ?? 0, description: milestone?.description ?? '', feedback: '', links: parseLinks(milestone?.links), @@ -92,6 +94,22 @@ export function StageModal({ mode, milestone, onSave, onClose, saving }: StageMo /> + +