fix: stage save errors and add milestone progress field
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "milestones" ADD COLUMN IF NOT EXISTS "progress" INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -162,6 +162,7 @@ model Milestone {
|
|||||||
description String?
|
description String?
|
||||||
startDate DateTime?
|
startDate DateTime?
|
||||||
dueDate DateTime?
|
dueDate DateTime?
|
||||||
|
progress Int @default(0)
|
||||||
links String? // JSON: [{ "label": string, "url": string }]
|
links String? // JSON: [{ "label": string, "url": string }]
|
||||||
completedAt DateTime?
|
completedAt DateTime?
|
||||||
order Int @default(0)
|
order Int @default(0)
|
||||||
|
|||||||
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 path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { prisma } from '../lib/prisma';
|
import { prisma } from '../lib/prisma';
|
||||||
|
import { resolveTaskActorId } from '../lib/resolveUser';
|
||||||
import { upload } from '../middleware/upload';
|
import { upload } from '../middleware/upload';
|
||||||
import { AppError } from '../middleware/errorHandler';
|
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, '단계를 찾을 수 없습니다.');
|
if (!milestone) throw new AppError(404, '단계를 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const uploadedBy = await resolveTaskActorId(taskId);
|
||||||
|
|
||||||
const fileRecord = await prisma.file.create({
|
const fileRecord = await prisma.file.create({
|
||||||
data: {
|
data: {
|
||||||
taskId,
|
taskId,
|
||||||
@@ -35,7 +38,7 @@ router.post('/upload/:taskId', upload.single('file'), async (req, res, next) =>
|
|||||||
mimetype: req.file.mimetype,
|
mimetype: req.file.mimetype,
|
||||||
size: req.file.size,
|
size: req.file.size,
|
||||||
path: req.file.path,
|
path: req.file.path,
|
||||||
uploadedBy: body.uploadedBy ?? 'system',
|
uploadedBy,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,33 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { prisma } from '../lib/prisma';
|
import { prisma } from '../lib/prisma';
|
||||||
|
import { resolveTaskActorId } from '../lib/resolveUser';
|
||||||
import { AppError } from '../middleware/errorHandler';
|
import { AppError } from '../middleware/errorHandler';
|
||||||
|
|
||||||
const router = Router();
|
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 {
|
function normalizeLinks(links: unknown): string | null {
|
||||||
if (!links) return null;
|
if (!links) return null;
|
||||||
if (typeof links === 'string') {
|
if (typeof links === 'string') {
|
||||||
try {
|
try {
|
||||||
JSON.parse(links);
|
const parsed = JSON.parse(links);
|
||||||
|
if (Array.isArray(parsed) && parsed.length === 0) return null;
|
||||||
return links;
|
return links;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (Array.isArray(links)) return JSON.stringify(links);
|
if (Array.isArray(links)) {
|
||||||
|
return links.length ? JSON.stringify(links) : null;
|
||||||
|
}
|
||||||
return 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
|
// GET /api/milestones/:taskId
|
||||||
router.get('/:taskId', async (req, res, next) => {
|
router.get('/:taskId', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
@@ -43,32 +45,33 @@ router.get('/:taskId', async (req, res, next) => {
|
|||||||
router.post('/:taskId', async (req, res, next) => {
|
router.post('/:taskId', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const taskId = req.params.taskId;
|
const taskId = req.params.taskId;
|
||||||
const { title, description, startDate, dueDate, feedback, links } =
|
const { title, description, startDate, dueDate, feedback, links, progress } =
|
||||||
req.body as Record<string, string>;
|
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 count = await prisma.milestone.count({ where: { taskId } });
|
||||||
|
|
||||||
const milestone = await prisma.milestone.create({
|
const milestone = await prisma.milestone.create({
|
||||||
data: {
|
data: {
|
||||||
taskId,
|
taskId,
|
||||||
title: title.trim(),
|
title: String(title).trim(),
|
||||||
description: description?.trim() || null,
|
description: description?.toString().trim() || null,
|
||||||
startDate: startDate ? new Date(startDate) : null,
|
startDate: startDate ? new Date(String(startDate)) : null,
|
||||||
dueDate: dueDate ? new Date(dueDate) : null,
|
dueDate: dueDate ? new Date(String(dueDate)) : null,
|
||||||
|
progress: progress !== undefined ? clampProgress(progress) : 0,
|
||||||
links: normalizeLinks(links),
|
links: normalizeLinks(links),
|
||||||
order: count,
|
order: count,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (feedback?.trim()) {
|
if (feedback?.toString().trim()) {
|
||||||
const updatedBy = await resolveUpdatedBy(taskId);
|
const updatedBy = await resolveTaskActorId(taskId);
|
||||||
await prisma.taskDetail.create({
|
await prisma.taskDetail.create({
|
||||||
data: {
|
data: {
|
||||||
taskId,
|
taskId,
|
||||||
milestoneId: milestone.id,
|
milestoneId: milestone.id,
|
||||||
content: feedback.trim(),
|
content: feedback.toString().trim(),
|
||||||
updatedBy,
|
updatedBy,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -83,7 +86,7 @@ router.post('/:taskId', async (req, res, next) => {
|
|||||||
// PATCH /api/milestones/item/:id
|
// PATCH /api/milestones/item/:id
|
||||||
router.patch('/item/:id', async (req, res, next) => {
|
router.patch('/item/:id', async (req, res, next) => {
|
||||||
try {
|
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>;
|
req.body as Record<string, string | boolean | number>;
|
||||||
|
|
||||||
const existing = await prisma.milestone.findUnique({ where: { id: req.params.id } });
|
const existing = await prisma.milestone.findUnique({ where: { id: req.params.id } });
|
||||||
@@ -94,18 +97,20 @@ router.patch('/item/:id', async (req, res, next) => {
|
|||||||
data: {
|
data: {
|
||||||
...(title !== undefined && { title: String(title).trim() }),
|
...(title !== undefined && { title: String(title).trim() }),
|
||||||
...(description !== undefined && { description: description ? String(description).trim() : null }),
|
...(description !== undefined && { description: description ? String(description).trim() : null }),
|
||||||
...(startDate !== undefined && { startDate: startDate ? new Date(startDate as string) : null }),
|
...(startDate !== undefined && { startDate: startDate ? new Date(String(startDate)) : null }),
|
||||||
...(dueDate !== undefined && { dueDate: dueDate ? new Date(dueDate as string) : null }),
|
...(dueDate !== undefined && { dueDate: dueDate ? new Date(String(dueDate)) : null }),
|
||||||
|
...(progress !== undefined && { progress: clampProgress(progress) }),
|
||||||
...(links !== undefined && { links: normalizeLinks(links) }),
|
...(links !== undefined && { links: normalizeLinks(links) }),
|
||||||
...(order !== undefined && { order: Number(order) }),
|
...(order !== undefined && { order: Number(order) }),
|
||||||
...(completed !== undefined && {
|
...(completed !== undefined && {
|
||||||
completedAt: completed ? new Date() : null,
|
completedAt: completed ? new Date() : null,
|
||||||
|
...(completed && { progress: 100 }),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (typeof feedback === 'string' && feedback.trim()) {
|
if (typeof feedback === 'string' && feedback.trim()) {
|
||||||
const updatedBy = await resolveUpdatedBy(existing.taskId);
|
const updatedBy = await resolveTaskActorId(existing.taskId);
|
||||||
await prisma.taskDetail.create({
|
await prisma.taskDetail.create({
|
||||||
data: {
|
data: {
|
||||||
taskId: existing.taskId,
|
taskId: existing.taskId,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface StageFormData {
|
|||||||
title: string;
|
title: string;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
dueDate: string;
|
dueDate: string;
|
||||||
|
progress: number;
|
||||||
description: string;
|
description: string;
|
||||||
feedback: string;
|
feedback: string;
|
||||||
links: MilestoneLink[];
|
links: MilestoneLink[];
|
||||||
@@ -39,6 +40,7 @@ export function StageModal({ mode, milestone, onSave, onClose, saving }: StageMo
|
|||||||
title: milestone?.title ?? '',
|
title: milestone?.title ?? '',
|
||||||
startDate: toDateInput(milestone?.startDate),
|
startDate: toDateInput(milestone?.startDate),
|
||||||
dueDate: toDateInput(milestone?.dueDate),
|
dueDate: toDateInput(milestone?.dueDate),
|
||||||
|
progress: milestone?.progress ?? 0,
|
||||||
description: milestone?.description ?? '',
|
description: milestone?.description ?? '',
|
||||||
feedback: '',
|
feedback: '',
|
||||||
links: parseLinks(milestone?.links),
|
links: parseLinks(milestone?.links),
|
||||||
@@ -92,6 +94,22 @@ export function StageModal({ mode, milestone, onSave, onClose, saving }: StageMo
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 flex items-center justify-between text-sm font-bold text-slate-500">
|
||||||
|
<span>진행률</span>
|
||||||
|
<span className="text-lg font-black text-emerald-600">{form.progress}%</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={5}
|
||||||
|
value={form.progress}
|
||||||
|
onChange={(e) => set('progress', Number(e.target.value))}
|
||||||
|
className="w-full accent-emerald-600"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-1 block text-sm font-bold text-slate-500">시작일</span>
|
<span className="mb-1 block text-sm font-bold text-slate-500">시작일</span>
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ function sortByIsoDesc<T>(items: T[], pick: (item: T) => string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function milestoneProgress(m: Milestone) {
|
function milestoneProgress(m: Milestone) {
|
||||||
return m.completedAt ? 100 : 0;
|
if (m.completedAt) return 100;
|
||||||
|
const p = m.progress ?? 0;
|
||||||
|
return Math.min(100, Math.max(0, p));
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseContentLines(text: string | null | undefined) {
|
function parseContentLines(text: string | null | undefined) {
|
||||||
@@ -274,7 +276,6 @@ function DetailView({ task }: { task: TaskWithRelations }) {
|
|||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('file', file);
|
form.append('file', file);
|
||||||
form.append('milestoneId', milestoneId);
|
form.append('milestoneId', milestoneId);
|
||||||
form.append('uploadedBy', task.creatorId);
|
|
||||||
await apiClient.post(`/files/upload/${task.id}`, form, {
|
await apiClient.post(`/files/upload/${task.id}`, form, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
});
|
});
|
||||||
@@ -289,21 +290,41 @@ function DetailView({ task }: { task: TaskWithRelations }) {
|
|||||||
description: data.description.trim() || undefined,
|
description: data.description.trim() || undefined,
|
||||||
startDate: data.startDate || undefined,
|
startDate: data.startDate || undefined,
|
||||||
dueDate: data.dueDate || undefined,
|
dueDate: data.dueDate || undefined,
|
||||||
|
progress: data.progress,
|
||||||
feedback: data.feedback.trim() || undefined,
|
feedback: data.feedback.trim() || undefined,
|
||||||
links: JSON.stringify(data.links),
|
links: data.links.length > 0 ? JSON.stringify(data.links) : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let milestoneId: string;
|
||||||
|
|
||||||
if (stageModal?.mode === 'add') {
|
if (stageModal?.mode === 'add') {
|
||||||
const { data: created } = await apiClient.post<Milestone>(`/milestones/${task.id}`, payload);
|
const { data: created } = await apiClient.post<Milestone>(`/milestones/${task.id}`, payload);
|
||||||
if (fileList.length) await uploadFiles(created.id, fileList);
|
milestoneId = created.id;
|
||||||
setSelectedId(created.id);
|
setSelectedId(created.id);
|
||||||
} else if (stageModal?.milestone) {
|
} else if (stageModal?.milestone) {
|
||||||
await apiClient.patch(`/milestones/item/${stageModal.milestone.id}`, payload);
|
const { data: updated } = await apiClient.patch<Milestone>(
|
||||||
if (fileList.length) await uploadFiles(stageModal.milestone.id, fileList);
|
`/milestones/item/${stageModal.milestone.id}`,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
milestoneId = updated.id;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileList.length > 0) {
|
||||||
|
try {
|
||||||
|
await uploadFiles(milestoneId, fileList);
|
||||||
|
} catch {
|
||||||
|
alert('단계는 저장됐지만 파일 업로드에 실패했습니다.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await qc.invalidateQueries({ queryKey: ['task', task.id] });
|
await qc.invalidateQueries({ queryKey: ['task', task.id] });
|
||||||
setStageModal(null);
|
setStageModal(null);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const ax = err as { response?: { data?: { message?: string } }; message?: string };
|
||||||
|
const msg = ax.response?.data?.message || ax.message || '단계 저장에 실패했습니다.';
|
||||||
|
alert(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setStageSaving(false);
|
setStageSaving(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export interface Milestone {
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
startDate: string | null;
|
startDate: string | null;
|
||||||
dueDate: string | null;
|
dueDate: string | null;
|
||||||
|
progress: number;
|
||||||
links: string | null;
|
links: string | null;
|
||||||
completedAt: string | null;
|
completedAt: string | null;
|
||||||
order: number;
|
order: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user