import { NextRequest, NextResponse } from "next/server" import { revalidateTag } from "next/cache" import db from "@/db/db" import { documents, issueStages, revisions, documentAttachments, } from "@/db/schema/vendorDocu" import { and, eq } from "drizzle-orm" /* 보안 강화된 파일 저장 유틸리티 */ import { saveFile, SaveFileResult } from "@/lib/file-stroage" /* change log 유틸 */ import { logRevisionChange, logAttachmentChange, } from "@/lib/vendor-document-list/sync-service" export async function POST(request: NextRequest) { try { const formData = await request.formData() /* ------- 파라미터 파싱 ------- */ const stage = formData.get("stage") as string | null const revision = formData.get("revision") as string | null const docId = Number(formData.get("documentId")) const uploaderName = formData.get("uploaderName") as string | null const usage = formData.get("usage") as string | null const usageType = formData.get("usageType") as string | null const comment = formData.get("comment") as string | null const mode = (formData.get("mode") || "new") as string // 'new'|'append' const targetSystem = (formData.get("targetSystem") as string | null) ?? "DOLCE" const attachmentFiles = formData.getAll("attachments") as File[] /* ------- 검증 ------- */ if (!docId || Number.isNaN(docId)) return NextResponse.json({ error: "Invalid documentId" }, { status: 400 }) if (!stage || !revision) return NextResponse.json({ error: "Missing stage or revision" }, { status: 400 }) if (!attachmentFiles.length) return NextResponse.json({ error: "No files provided" }, { status: 400 }) // 기본 파일 크기 검증 (보안 함수에서도 검증하지만 조기 체크) const MAX = 3 * 1024 * 1024 * 1024 // 3 GB for (const f of attachmentFiles) { if (f.size > MAX) { return NextResponse.json( { error: `${f.name} > 3 GB` }, { status: 400 } ) } } /* ------- 계약 ID 확보 ------- */ const [docInfo] = await db .select({ projectId: documents.projectId }) .from(documents) .where(eq(documents.id, docId)) .limit(1) if (!docInfo) { return NextResponse.json({ error: "Document not found" }, { status: 404 }) } // projectId가 null인 경우 처리 if (!docInfo.projectId) { return NextResponse.json({ error: "Document must have a valid project ID for synchronization" }, { status: 400 }) } /* ------- 트랜잭션 ------- */ const result = await db.transaction(async (tx) => { /* 1) Stage 생성/조회 */ let issueStageId: number const [stageRow] = await tx .select({ id: issueStages.id }) .from(issueStages) .where(and( eq(issueStages.stageName, stage), eq(issueStages.documentId, docId) )) .limit(1) if (!stageRow) { const [s] = await tx.insert(issueStages) .values({ documentId: docId, stageName: stage, updatedAt: new Date() }) .returning({ id: issueStages.id }) issueStageId = s.id } else { issueStageId = stageRow.id } /* 2) Revision 생성/업데이트 */ const today = new Date().toISOString().slice(0, 10) let revisionId: number const [revRow] = await tx .select() .from(revisions) .where(and( eq(revisions.issueStageId, issueStageId), eq(revisions.revision, revision) )) .limit(1) if (!revRow || mode === "new") { /* --- CREATE --- */ const revisionData: any = { issueStageId, revision, uploaderType: "vendor", uploaderName: uploaderName ?? undefined, revisionStatus: "UPLOADED", uploadedAt: today, comment: comment ?? undefined, updatedAt: new Date(), } // usage와 usageType이 있으면 추가 if (usage) revisionData.usage = usage if (usageType) revisionData.usageType = usageType const [newRev] = await tx.insert(revisions) .values(revisionData) .returning() revisionId = newRev.id // change_logs: CREATE await logRevisionChange( docInfo.projectId!, // null 체크 후이므로 non-null assertion 사용 revisionId, "CREATE", newRev, undefined, undefined, uploaderName ?? undefined, [targetSystem] ) } else { /* --- UPDATE --- */ const updateData: any = { uploaderName: uploaderName ?? revRow.uploaderName, comment: comment ?? revRow.comment, updatedAt: new Date(), } // usage와 usageType이 있으면 업데이트 if (usage) updateData.usage = usage if (usageType) updateData.usageType = usageType await tx.update(revisions) .set(updateData) .where(eq(revisions.id, revRow.id)) const [updated] = await tx .select() .from(revisions) .where(eq(revisions.id, revRow.id)) revisionId = revRow.id await logRevisionChange( docInfo.projectId!, // null 체크 후이므로 non-null assertion 사용 revisionId, "UPDATE", updated, revRow, undefined, uploaderName ?? undefined, [targetSystem] ) } /* ------- 보안 강화된 첨부파일 처리 ------- */ const uploadedFiles: any[] = [] const securityFailures: string[] = [] for (const file of attachmentFiles) { console.log(`🔐 보안 검증 시작: ${file.name}`) // 보안 강화된 파일 저장 const saveResult: SaveFileResult = await saveFile({ file, directory: "documents", // 문서 전용 디렉토리 originalName: file.name, userId: uploaderName || "anonymous", // 업로더 정보 로깅용 }) if (!saveResult.success) { console.error(`❌ 파일 보안 검증 실패: ${file.name} - ${saveResult.error}`) securityFailures.push(`${file.name}: ${saveResult.error}`) continue // 실패한 파일은 건너뛰고 계속 진행 } console.log(`✅ 파일 보안 검증 통과: ${file.name}`) console.log(`📁 저장된 경로: ${saveResult.publicPath}`) // DB에 첨부파일 정보 저장 const [att] = await tx.insert(documentAttachments) .values({ revisionId, fileName: saveResult.originalName!, // 원본 파일명 filePath: saveResult.publicPath!, // 웹 접근 경로 fileSize: saveResult.fileSize!, fileType: saveResult.fileName!.split('.').pop()?.toLowerCase() || undefined, updatedAt: new Date(), }) .returning() uploadedFiles.push({ id: att.id, fileName: saveResult.originalName, fileSize: saveResult.fileSize, filePath: saveResult.publicPath, fileType: saveResult.fileName!.split('.').pop()?.toLowerCase() || null, securityChecks: saveResult.securityChecks, // 보안 검증 결과 }) // change_logs: attachment CREATE await logAttachmentChange( docInfo.projectId, att.id, "CREATE", att, undefined, undefined, uploaderName ?? undefined, [targetSystem] ) } // 보안 검증 실패한 파일이 있으면 경고 반환 if (securityFailures.length > 0) { console.warn(`⚠️ 일부 파일의 보안 검증 실패:`, securityFailures) // 모든 파일이 실패한 경우 에러 반환 if (uploadedFiles.length === 0) { throw new Error(`모든 파일의 보안 검증이 실패했습니다: ${securityFailures.join(', ')}`) } } /* 4) documents.updatedAt 업데이트 */ await tx.update(documents) .set({ updatedAt: new Date() }) .where(eq(documents.id, docId)) return { revisionId, stage, revision, uploadedFiles, mode, projectId: docInfo.projectId, usage, usageType, securityFailures // 보안 실패 정보 포함 } }) // 캐시 무효화 - 트랜잭션 완료 후에 실행 try { // enhanced documents 캐시 무효화 revalidateTag(`enhanced-documents-${result.projectId}`) // sync status 관련 캐시도 무효화 (필요시) revalidateTag(`sync-status-${result.projectId}`) console.log(`✅ Cache invalidated for contract ${result.projectId}`) } catch (cacheError) { console.warn('⚠️ Cache invalidation failed:', cacheError) // 캐시 무효화 실패해도 업로드는 성공으로 처리 } // 응답 메시지 구성 let message = `${result.uploadedFiles.length}개 파일 업로드 완료` if (result.securityFailures.length > 0) { message += ` (일부 파일 보안 검증 실패: ${result.securityFailures.length}개)` } return NextResponse.json({ success: true, message, data: { revisionId: result.revisionId, stage: result.stage, revision: result.revision, mode: result.mode, usage: result.usage, usageType: result.usageType, uploaderName: uploaderName, uploadedFiles: result.uploadedFiles, filesCount: result.uploadedFiles.length, securityFailures: result.securityFailures, // 클라이언트에 보안 실패 정보 전달 projectId: result.projectId, }, }) } catch (e) { console.error("revision-upload error:", e) return NextResponse.json( { error: "Failed to upload revision", details: String(e) }, { status: 500 }, ) } }