feat: detail page attachments, preview, and file metadata
Add file displayName/sortOrder APIs, result preview with Excel/video support, unified attachment/link editing, feedback modal, and AuthProvider fix. Run prisma migrate deploy on Render builds. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
ALTER TABLE "files" ADD COLUMN IF NOT EXISTS "displayName" TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "files" ADD COLUMN IF NOT EXISTS "sortOrder" INTEGER NOT NULL DEFAULT 0;
|
||||
CREATE INDEX IF NOT EXISTS "files_milestoneId_sortOrder_idx" ON "files"("milestoneId", "sortOrder");
|
||||
@@ -138,6 +138,8 @@ model File {
|
||||
milestoneId String?
|
||||
filename String // 저장된 파일명 (UUID)
|
||||
originalName String // 원본 파일명
|
||||
displayName String? // 표시명 (없으면 originalName 사용)
|
||||
sortOrder Int @default(0)
|
||||
mimetype String
|
||||
size Int
|
||||
path String
|
||||
|
||||
82
backend/src/routes/details.ts
Normal file
82
backend/src/routes/details.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Router } from 'express';
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { resolveTaskActorId } from '../lib/resolveUser';
|
||||
import { AppError } from '../middleware/errorHandler';
|
||||
|
||||
const router = Router();
|
||||
|
||||
async function resolveAuthorId(taskId: string, authorName?: string): Promise<string> {
|
||||
const name = authorName?.toString().trim();
|
||||
if (name) {
|
||||
const user = await prisma.user.findFirst({ where: { name } });
|
||||
if (user) return user.id;
|
||||
}
|
||||
return resolveTaskActorId(taskId);
|
||||
}
|
||||
|
||||
// POST /api/details/:taskId
|
||||
router.post('/:taskId', async (req, res, next) => {
|
||||
try {
|
||||
const taskId = req.params.taskId;
|
||||
const { content, milestoneId, authorName } = req.body as Record<string, string>;
|
||||
|
||||
if (!content?.toString().trim()) throw new AppError(400, '피드백 내용은 필수입니다.');
|
||||
|
||||
const task = await prisma.task.findUnique({ where: { id: taskId }, select: { id: true } });
|
||||
if (!task) throw new AppError(404, '업무를 찾을 수 없습니다.');
|
||||
|
||||
if (milestoneId) {
|
||||
const ms = await prisma.milestone.findFirst({ where: { id: milestoneId, taskId } });
|
||||
if (!ms) throw new AppError(400, '유효하지 않은 업무 단계입니다.');
|
||||
}
|
||||
|
||||
const updatedBy = await resolveAuthorId(taskId, authorName);
|
||||
|
||||
const detail = await prisma.taskDetail.create({
|
||||
data: {
|
||||
taskId,
|
||||
milestoneId: milestoneId || null,
|
||||
content: content.toString().trim(),
|
||||
updatedBy,
|
||||
},
|
||||
include: { author: { select: { id: true, name: true } } },
|
||||
});
|
||||
|
||||
res.status(201).json(detail);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/details/item/:id
|
||||
router.patch('/item/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { content } = req.body as Record<string, string>;
|
||||
if (!content?.toString().trim()) throw new AppError(400, '피드백 내용은 필수입니다.');
|
||||
|
||||
const existing = await prisma.taskDetail.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) throw new AppError(404, '피드백을 찾을 수 없습니다.');
|
||||
|
||||
const detail = await prisma.taskDetail.update({
|
||||
where: { id: req.params.id },
|
||||
data: { content: content.toString().trim() },
|
||||
include: { author: { select: { id: true, name: true } } },
|
||||
});
|
||||
|
||||
res.json(detail);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/details/item/:id
|
||||
router.delete('/item/:id', async (req, res, next) => {
|
||||
try {
|
||||
await prisma.taskDetail.delete({ where: { id: req.params.id } });
|
||||
res.status(204).send();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -8,6 +8,15 @@ import { AppError } from '../middleware/errorHandler';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/** multer가 latin1로 전달하는 한글 파일명 복원 */
|
||||
function fixOriginalName(name: string): string {
|
||||
try {
|
||||
return Buffer.from(name, 'latin1').toString('utf8') || name;
|
||||
} catch {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/files/upload/:taskId — 파일 업로드
|
||||
router.post('/upload/:taskId', upload.single('file'), async (req, res, next) => {
|
||||
try {
|
||||
@@ -29,12 +38,17 @@ router.post('/upload/:taskId', upload.single('file'), async (req, res, next) =>
|
||||
|
||||
const uploadedBy = await resolveTaskActorId(taskId);
|
||||
|
||||
const displayName = body.displayName?.trim() || null;
|
||||
const sortOrder = body.sortOrder !== undefined ? Number(body.sortOrder) : 0;
|
||||
|
||||
const fileRecord = await prisma.file.create({
|
||||
data: {
|
||||
taskId,
|
||||
milestoneId,
|
||||
filename: req.file.filename,
|
||||
originalName: req.file.originalname,
|
||||
originalName: fixOriginalName(req.file.originalname),
|
||||
displayName,
|
||||
sortOrder: Number.isNaN(sortOrder) ? 0 : sortOrder,
|
||||
mimetype: req.file.mimetype,
|
||||
size: req.file.size,
|
||||
path: req.file.path,
|
||||
@@ -77,6 +91,56 @@ router.get('/:id/download', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/files/:id/replace — 파일 교체
|
||||
router.post('/:id/replace', upload.single('file'), async (req, res, next) => {
|
||||
try {
|
||||
if (!req.file) throw new AppError(400, '파일이 없습니다.');
|
||||
|
||||
const existing = await prisma.file.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) throw new AppError(404, '파일을 찾을 수 없습니다.');
|
||||
|
||||
if (fs.existsSync(existing.path)) {
|
||||
fs.unlinkSync(existing.path);
|
||||
}
|
||||
|
||||
const file = await prisma.file.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
filename: req.file.filename,
|
||||
originalName: fixOriginalName(req.file.originalname),
|
||||
mimetype: req.file.mimetype,
|
||||
size: req.file.size,
|
||||
path: req.file.path,
|
||||
},
|
||||
});
|
||||
|
||||
res.json(file);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/files/:id — 표시명·순서 수정
|
||||
router.patch('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { displayName, sortOrder } = req.body as { displayName?: string; sortOrder?: number };
|
||||
const existing = await prisma.file.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) throw new AppError(404, '파일을 찾을 수 없습니다.');
|
||||
|
||||
const file = await prisma.file.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
...(displayName !== undefined && { displayName: displayName?.trim() || null }),
|
||||
...(sortOrder !== undefined && { sortOrder: Number(sortOrder) }),
|
||||
},
|
||||
});
|
||||
|
||||
res.json(file);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/files/:id — 파일 삭제
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
try {
|
||||
|
||||
@@ -6,6 +6,7 @@ import fileRoutes from './files';
|
||||
import kpiRoutes from './kpi';
|
||||
import columnRoutes from './columns';
|
||||
import milestoneRoutes from './milestones';
|
||||
import detailRoutes from './details';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -16,5 +17,6 @@ router.use('/files', fileRoutes);
|
||||
router.use('/kpi', kpiRoutes);
|
||||
router.use('/columns', columnRoutes);
|
||||
router.use('/milestones', milestoneRoutes);
|
||||
router.use('/details', detailRoutes);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -38,7 +38,10 @@ router.get('/:id', async (req, res, next) => {
|
||||
include: {
|
||||
assignee: { select: { id: true, name: true, department: true } },
|
||||
creator: { select: { id: true, name: true } },
|
||||
details: { orderBy: { createdAt: 'desc' } },
|
||||
details: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { author: { select: { id: true, name: true } } },
|
||||
},
|
||||
kpiMetrics: true,
|
||||
files: true,
|
||||
milestones: { orderBy: { order: 'asc' } },
|
||||
|
||||
Reference in New Issue
Block a user