import { NextRequest, NextResponse } from 'next/server'; import { revalidateTag } from 'next/cache'; import { getServerSession } from 'next-auth/next'; import { authOptions } from '@/app/api/auth/[...nextauth]/route'; import db from '@/db/db'; import { documents, issueStages, revisions, documentAttachments, } from '@/db/schema/vendorDocu'; import { and, eq } from 'drizzle-orm'; /* 보안 강화된 파일 저장 유틸리티 */ import { saveFile, SaveFileResult, saveFileStream } from '@/lib/file-stroage'; /* change log 유틸 */ import { logRevisionChange, logAttachmentChange, } from '@/lib/vendor-document-list/sync-service'; export async function POST(request: NextRequest) { try { const session = await getServerSession(authOptions); if (!session?.user?.id) { return NextResponse.json({ error: 'unauthorized' }); } const currentUserId = Number(session.user.id); const currentUserName = session.user.name || session.user.email || 'no name & email'; const formData = await request.formData(); /* ------- 파라미터 파싱 ------- */ const usage = formData.get('usage') as string | null; const usageType = formData.get('usageType') 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 comment = formData.get('comment') as string | null; const targetSystem = 'DOLCE'; const attachmentFiles = formData.getAll('attachments') as File[]; // const issueStageId = formData.get("issueStageId") as string const serialNo = formData.get('serialNo') as string; /* ------- 검증 ------- */ if (!docId || Number.isNaN(docId)) return NextResponse.json( { error: 'Invalid documentId' }, { status: 400 } ); if (!usage || !revision) return NextResponse.json( { error: 'Missing usage or revision' }, { status: 400 } ); if (!attachmentFiles.length) return NextResponse.json({ error: 'No files provided' }, { status: 400 }); // 기본 파일 크기 검증 (보안 함수에서도 검증하지만 조기 체크) const MAX = 1024 * 1024 * 1024; for (const f of attachmentFiles) { if (f.size > MAX) { return NextResponse.json( { error: `${f.name} exceeds 1GB limit` }, { status: 400 } ); } } /* ------- 계약 ID 확보 ------- */ const [docInfo] = await db .select({ contractId: documents.contractId, projectId: documents.projectId, vendorId: documents.vendorId, }) .from(documents) .where(eq(documents.id, docId)) .limit(1); if (!docInfo) { return NextResponse.json( { error: 'Document not found' }, { status: 404 } ); } // projectId가 null인 경우 처리 if (!docInfo.vendorId) { return NextResponse.json( { error: 'Document must have a valid project ID for synchronization', }, { status: 400 } ); } /* ------- Stage 찾기 로직 ------- */ // 1. usage 값과 일치하는 stage 찾기 let targetStage = await db .select({ id: issueStages.id, stageName: issueStages.stageName }) .from(issueStages) .where( and(eq(issueStages.documentId, docId), eq(issueStages.stageName, usage)) ) .limit(1); // 2. 없으면 해당 문서의 첫 번째 stage 사용 if (!targetStage.length) { targetStage = await db .select({ id: issueStages.id, stageName: issueStages.stageName }) .from(issueStages) .where(eq(issueStages.documentId, docId)) .orderBy(issueStages.id) // 첫 번째 stage .limit(1); } if (!targetStage.length) { return NextResponse.json( { error: 'No stages found for this document', }, { status: 400 } ); } const stage = targetStage[0].stageName; const issueStageId = targetStage[0].id; /* ------- 트랜잭션 ------- */ const result = await db.transaction(async (tx) => { /* Revision 생성/업데이트 */ const today = new Date().toISOString().slice(0, 10); // 동일한 revision이 이미 있는지 확인 (usage, usageType도 포함) const whereConditions = [ eq(revisions.issueStageId, issueStageId), eq(revisions.revision, revision), eq(revisions.serialNo, serialNo), eq(revisions.usage, usage), ]; // usageType이 있는 경우에만 조건에 추가 if (usageType) { whereConditions.push(eq(revisions.usageType, usageType)); } const [existingRev] = await tx .select() .from(revisions) .where(and(...whereConditions)) .limit(1); let revisionId: number; let revisionData: any; if (existingRev) { // 기존 revision 업데이트 const updateData: any = { uploaderName: uploaderName ?? existingRev.uploaderName, comment: comment ?? existingRev.comment, updatedAt: new Date(), }; // usage는 항상 업데이트 updateData.usage = usage; // usageType이 있는 경우에만 업데이트 if (usageType) { updateData.usageType = usageType; } await tx .update(revisions) .set(updateData) .where(eq(revisions.id, existingRev.id)); const [updated] = await tx .select() .from(revisions) .where(eq(revisions.id, existingRev.id)); revisionId = existingRev.id; revisionData = updated; await logRevisionChange( docInfo.vendorId!, // null 체크 후이므로 non-null assertion 사용 revisionId, 'UPDATE', updated, existingRev, currentUserId, currentUserName, [targetSystem] ); } else { // 새 revision 생성 const [newRev] = await tx .insert(revisions) .values({ issueStageId, serialNo: serialNo, revision, usage, usageType, uploaderType: 'vendor', uploaderName: uploaderName ?? undefined, revisionStatus: 'UPLOADED', uploadedAt: today, comment: comment ?? undefined, updatedAt: new Date(), }) .returning(); revisionId = newRev.id; revisionData = newRev; await logRevisionChange( docInfo.vendorId!, // null 체크 후이므로 non-null assertion 사용 revisionId, 'CREATE', newRev, undefined, currentUserId, currentUserName, [targetSystem] ); } /* ------- 보안 강화된 첨부파일 처리 ------- */ const uploadedFiles: any[] = []; const securityFailures: string[] = []; for (const file of attachmentFiles) { console.log(`🔐 보안 검증 시작: ${file.name}`); // 보안 강화된 파일 저장 const saveResult = file.size > 100 * 1024 * 1024 ? await saveFileStream({ file, directory: 'documents', userId: uploaderName || '', }) : await saveFile({ file, directory: 'documents', userId: uploaderName || '', }); 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.vendorId!, att.id, 'CREATE', att, undefined, currentUserId, currentUserName, [targetSystem] ); } // 보안 검증 실패한 파일이 있으면 경고 반환 if (securityFailures.length > 0) { console.warn(`⚠️ 일부 파일의 보안 검증 실패:`, securityFailures); // 모든 파일이 실패한 경우 에러 반환 if (uploadedFiles.length === 0) { throw new Error( `모든 파일의 보안 검증이 실패했습니다: ${securityFailures.join( ', ' )}` ); } } /* documents.updatedAt 업데이트 */ await tx .update(documents) .set({ updatedAt: new Date() }) .where(eq(documents.id, docId)); return { revisionId, issueStageId, stage, revision, uploadedFiles, vendorId: docInfo.vendorId, usage, usageType, securityFailures, // 보안 실패 정보 포함 }; }); // 캐시 무효화 try { revalidateTag(`sync-status-${result.vendorId}`); console.log(`✅ Cache invalidated for contract ${result.vendorId}`); } catch (cacheError) { console.warn('⚠️ Cache invalidation failed:', cacheError); } // 응답 메시지 구성 let message = `리비전 ${result.revision}이 성공적으로 업로드되었습니다`; if (result.securityFailures.length > 0) { message += ` (일부 파일 보안 검증 실패: ${result.securityFailures.length}개)`; } return NextResponse.json({ success: true, message, data: { revisionId: result.revisionId, issueStageId: result.issueStageId, serialNo: serialNo, stage: result.stage, revision: result.revision, usage: result.usage, usageType: result.usageType, uploaderName: uploaderName, uploadedFiles: result.uploadedFiles, filesCount: result.uploadedFiles.length, securityFailures: result.securityFailures, // 클라이언트에 보안 실패 정보 전달 }, }); } catch (e) { console.error('revision-upload error:', e); return NextResponse.json( { error: 'Failed to upload revision', details: String(e) }, { status: 500 } ); } }