216 lines
7.2 KiB
TypeScript
216 lines
7.2 KiB
TypeScript
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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;
|