import { Router } from 'express'; import { Prisma } from '@prisma/client'; import { prisma } from '../lib/prisma'; import { resolveTaskActorId } from '../lib/resolveUser'; import { formatMilestone, milestoneInclude, parseMemberIds } from '../lib/taskQuery'; import { resolveMilestonePeriodPayload } from '../lib/milestonePeriods'; import { AppError } from '../middleware/errorHandler'; const router = Router(); function normalizeLinks(links: unknown): string | null { if (!links) return null; if (typeof links === 'string') { try { const parsed = JSON.parse(links); if (Array.isArray(parsed) && parsed.length === 0) return null; return links; } catch { return null; } } 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))); } async function syncMilestoneMembers( milestoneId: string, pmMemberId: string | null | undefined, assigneeMemberIds: string[] | undefined, ) { if (pmMemberId !== undefined) { await prisma.milestone.update({ where: { id: milestoneId }, data: { pmMemberId: pmMemberId || null }, }); } if (assigneeMemberIds !== undefined) { await prisma.milestoneAssignee.deleteMany({ where: { milestoneId } }); const ids = [...new Set(assigneeMemberIds.filter(Boolean))]; if (ids.length > 0) { await prisma.milestoneAssignee.createMany({ data: ids.map((memberId) => ({ milestoneId, memberId })), }); } } } async function loadMilestone(id: string) { const milestone = await prisma.milestone.findUnique({ where: { id }, include: milestoneInclude, }); if (!milestone) throw new AppError(404, '단계를 찾을 수 없습니다.'); return formatMilestone(milestone); } // GET /api/milestones/:taskId router.get('/:taskId', async (req, res, next) => { try { const milestones = await prisma.milestone.findMany({ where: { taskId: req.params.taskId }, orderBy: { order: 'asc' }, include: milestoneInclude, }); res.json(milestones.map((m) => formatMilestone(m))); } catch (err) { next(err); } }); // POST /api/milestones/:taskId router.post('/:taskId', async (req, res, next) => { try { const taskId = req.params.taskId; const body = req.body as Record; const { title, subtitle, description, startDate, dueDate, feedback, links, progress } = body; const assigneeMemberIds = parseMemberIds(body); const pmMemberId = body.pmMemberId !== undefined ? String(body.pmMemberId || '') || null : undefined; if (!title?.toString().trim()) throw new AppError(400, '단계 제목은 필수입니다.'); const count = await prisma.milestone.count({ where: { taskId } }); const periodPayload = resolveMilestonePeriodPayload(body); const milestone = await prisma.milestone.create({ data: { taskId, title: String(title).trim(), subtitle: subtitle !== undefined ? String(subtitle || '').trim() || null : null, description: description?.toString().trim() || null, startDate: periodPayload.startDate !== undefined ? periodPayload.startDate : startDate ? new Date(String(startDate)) : null, dueDate: periodPayload.dueDate !== undefined ? periodPayload.dueDate : dueDate ? new Date(String(dueDate)) : null, periodEntries: periodPayload.periodEntries !== undefined ? (periodPayload.periodEntries as Prisma.InputJsonValue) : undefined, progress: progress !== undefined ? clampProgress(progress) : 0, links: normalizeLinks(links), order: count, ...(pmMemberId !== undefined ? { pmMemberId } : {}), }, }); await syncMilestoneMembers(milestone.id, pmMemberId, assigneeMemberIds); if (feedback?.toString().trim()) { const updatedBy = await resolveTaskActorId(taskId); await prisma.taskDetail.create({ data: { taskId, milestoneId: milestone.id, content: feedback.toString().trim(), updatedBy, }, }); } res.status(201).json(await loadMilestone(milestone.id)); } catch (err) { next(err); } }); // PATCH /api/milestones/item/:id router.patch('/item/:id', async (req, res, next) => { try { const body = req.body as Record; const { title, subtitle, description, startDate, dueDate, feedback, links, progress, completed, order } = body; const assigneeMemberIds = parseMemberIds(body); const pmMemberId = body.pmMemberId !== undefined ? String(body.pmMemberId || '') || null : undefined; const existing = await prisma.milestone.findUnique({ where: { id: req.params.id } }); if (!existing) throw new AppError(404, '단계를 찾을 수 없습니다.'); const periodPayload = resolveMilestonePeriodPayload(body); const milestone = await prisma.milestone.update({ where: { id: req.params.id }, data: { ...(title !== undefined && { title: String(title).trim() }), ...(subtitle !== undefined && { subtitle: String(subtitle || '').trim() || null }), ...(description !== undefined && { description: description ? String(description).trim() : null }), ...(periodPayload.startDate !== undefined && { startDate: periodPayload.startDate }), ...(periodPayload.dueDate !== undefined && { dueDate: periodPayload.dueDate }), ...(periodPayload.periodEntries !== undefined && { periodEntries: periodPayload.periodEntries as Prisma.InputJsonValue, }), ...(periodPayload.startDate === undefined && startDate !== undefined && { startDate: startDate ? new Date(String(startDate)) : null }), ...(periodPayload.dueDate === undefined && 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 }), }), ...(pmMemberId !== undefined ? { pmMemberId } : {}), }, }); await syncMilestoneMembers(milestone.id, pmMemberId, assigneeMemberIds); if (typeof feedback === 'string' && feedback.trim()) { const updatedBy = await resolveTaskActorId(existing.taskId); await prisma.taskDetail.create({ data: { taskId: existing.taskId, milestoneId: milestone.id, content: feedback.trim(), updatedBy, }, }); } res.json(await loadMilestone(milestone.id)); } catch (err) { next(err); } }); // DELETE /api/milestones/item/:id router.delete('/item/:id', async (req, res, next) => { try { await prisma.milestone.delete({ where: { id: req.params.id } }); res.status(204).send(); } catch (err) { next(err); } }); export default router;