fix: stage save errors and add milestone progress field
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
23
backend/src/lib/resolveUser.ts
Normal file
23
backend/src/lib/resolveUser.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { prisma } from './prisma';
|
||||
import { AppError } from '../middleware/errorHandler';
|
||||
|
||||
/** task 작성자 또는 관리자 등 유효한 user id 반환 (FK 오류 방지) */
|
||||
export async function resolveTaskActorId(taskId: string): Promise<string> {
|
||||
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, '사용자를 찾을 수 없습니다. 관리자 계정을 먼저 생성해 주세요.');
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string> {
|
||||
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<string, string>;
|
||||
const { title, description, startDate, dueDate, feedback, links, progress } =
|
||||
req.body as Record<string, string | number>;
|
||||
|
||||
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<string, string | boolean | number>;
|
||||
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user