diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-25 03:28:27 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-25 03:28:27 +0000 |
| commit | 4c2d4c235bd80368e31cae9c375e9a585f6a6844 (patch) | |
| tree | 7fd1847e1e30ef2052281453bfb7a1c45ac6627a /app/api | |
| parent | f69e125f1a0b47bbc22e2784208bf829bcdd24f8 (diff) | |
(대표님) archiver 추가, 데이터룸구현
Diffstat (limited to 'app/api')
17 files changed, 1972 insertions, 2 deletions
diff --git a/app/api/data-room/[projectId]/[fileId]/download/route.ts b/app/api/data-room/[projectId]/[fileId]/download/route.ts new file mode 100644 index 00000000..3a3a8fdd --- /dev/null +++ b/app/api/data-room/[projectId]/[fileId]/download/route.ts @@ -0,0 +1,246 @@ +// app/api/data-room/[projectId]/[fileId]/download/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { FileService, type FileAccessContext } from '@/lib/services/fileService'; +import { promises as fs } from 'fs'; +import path from 'path'; +import db from "@/db/db"; +import { fileItems } from "@/db/schema/fileSystem"; +import { eq } from "drizzle-orm"; + +export async function GET( + request: NextRequest, + { params }: { params: { projectId: string; fileId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const context: FileAccessContext = { + userId: Number(session.user.id), + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + const fileService = new FileService(); + + // 파일 접근 권한 확인 + const hasAccess = await fileService.checkFileAccess( + params.fileId, + context, + 'download' + ); + + if (!hasAccess) { + return NextResponse.json( + { error: '파일 다운로드 권한이 없습니다' }, + { status: 403 } + ); + } + + // FileService를 통해 파일 정보 가져오기 (다운로드 카운트 증가 및 로그 기록) + const file = await fileService.downloadFile(params.fileId, context); + + if (!file) { + return NextResponse.json( + { error: '파일을 찾을 수 없습니다' }, + { status: 404 } + ); + } + + // 파일 경로 확인 + if (!file.filePath) { + return NextResponse.json( + { error: '파일 경로가 없습니다' }, + { status: 404 } + ); + } + + // 실제 파일 경로 구성 + const nasPath = process.env.NAS_PATH || "/evcp_nas"; + const isProduction = process.env.NODE_ENV === "production"; + + let absolutePath: string; + if (isProduction) { + // 프로덕션: NAS 경로 사용 + const relativePath = file.filePath.replace('/api/files/', ''); + absolutePath = path.join(nasPath, relativePath); + } else { + // 개발: public 폴더 사용 + absolutePath = path.join(process.cwd(), 'public', file.filePath); + } + + // 파일 존재 여부 확인 + try { + await fs.access(absolutePath); + } catch (error) { + console.error('파일을 찾을 수 없습니다:', absolutePath); + return NextResponse.json( + { error: '파일을 찾을 수 없습니다' }, + { status: 404 } + ); + } + + // 파일 읽기 + const fileBuffer = await fs.readFile(absolutePath); + + // MIME 타입 결정 + const mimeType = getMimeType(file.name, file.mimeType); + + // 파일명 인코딩 (한글 등 특수문자 처리) + const encodedFileName = encodeURIComponent(file.name); + + // Response Headers 설정 + const headers = new Headers(); + headers.set('Content-Type', mimeType); + headers.set('Content-Length', fileBuffer.length.toString()); + headers.set('Content-Disposition', `attachment; filename*=UTF-8''${encodedFileName}`); + headers.set('Cache-Control', 'no-cache, no-store, must-revalidate'); + headers.set('Pragma', 'no-cache'); + headers.set('Expires', '0'); + + // 보안 헤더 추가 + headers.set('X-Content-Type-Options', 'nosniff'); + headers.set('X-Frame-Options', 'DENY'); + headers.set('X-XSS-Protection', '1; mode=block'); + + // 파일 스트림 반환 + return new NextResponse(fileBuffer, { + status: 200, + headers, + }); + + } catch (error) { + console.error('파일 다운로드 오류:', error); + + if (error instanceof Error) { + if (error.message.includes('권한')) { + return NextResponse.json( + { error: error.message }, + { status: 403 } + ); + } + } + + return NextResponse.json( + { error: '파일 다운로드에 실패했습니다' }, + { status: 500 } + ); + } +} + +// HEAD 요청 처리 (파일 정보만 확인) +export async function HEAD( + request: NextRequest, + { params }: { params: { projectId: string; fileId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return new NextResponse(null, { status: 401 }); + } + + const context: FileAccessContext = { + userId: Number(session.user.id), + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + const fileService = new FileService(); + + // 파일 접근 권한 확인 + const hasAccess = await fileService.checkFileAccess( + params.fileId, + context, + 'view' // HEAD 요청은 view 권한만 확인 + ); + + if (!hasAccess) { + return new NextResponse(null, { status: 403 }); + } + + // 파일 정보 조회 + const file = await db.query.fileItems.findFirst({ + where: eq(fileItems.id, params.fileId), + }); + + if (!file || !file.filePath) { + return new NextResponse(null, { status: 404 }); + } + + const headers = new Headers(); + headers.set('Content-Type', getMimeType(file.name, file.mimeType)); + headers.set('Content-Length', file.size?.toString() || '0'); + headers.set('Last-Modified', new Date(file.updatedAt).toUTCString()); + + return new NextResponse(null, { + status: 200, + headers, + }); + + } catch (error) { + console.error('HEAD 요청 오류:', error); + return new NextResponse(null, { status: 500 }); + } +} + +// MIME 타입 결정 헬퍼 함수 +function getMimeType(fileName: string, storedMimeType?: string | null): string { + // DB에 저장된 MIME 타입이 있으면 우선 사용 + if (storedMimeType) { + return storedMimeType; + } + + // 확장자 기반 MIME 타입 매핑 + const ext = path.extname(fileName).toLowerCase().substring(1); + const mimeTypes: Record<string, string> = { + // Documents + 'pdf': 'application/pdf', + 'doc': 'application/msword', + 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'xls': 'application/vnd.ms-excel', + 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'ppt': 'application/vnd.ms-powerpoint', + 'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'txt': 'text/plain', + 'csv': 'text/csv', + + // Images + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'png': 'image/png', + 'gif': 'image/gif', + 'bmp': 'image/bmp', + 'webp': 'image/webp', + 'svg': 'image/svg+xml', + + // Archives + 'zip': 'application/zip', + 'rar': 'application/x-rar-compressed', + '7z': 'application/x-7z-compressed', + + // CAD + 'dwg': 'application/x-dwg', + 'dxf': 'application/x-dxf', + + // Video + 'mp4': 'video/mp4', + 'avi': 'video/x-msvideo', + 'mov': 'video/quicktime', + 'wmv': 'video/x-ms-wmv', + + // Audio + 'mp3': 'audio/mpeg', + 'wav': 'audio/wav', + 'ogg': 'audio/ogg', + }; + + return mimeTypes[ext] || 'application/octet-stream'; +}
\ No newline at end of file diff --git a/app/api/data-room/[projectId]/[fileId]/route.ts b/app/api/data-room/[projectId]/[fileId]/route.ts new file mode 100644 index 00000000..176aaf63 --- /dev/null +++ b/app/api/data-room/[projectId]/[fileId]/route.ts @@ -0,0 +1,147 @@ +// app/api/files/[projectId]/[fileId]/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route' +import { FileService, type FileAccessContext } from '@/lib/services/fileService'; + +// 파일 정보 조회 +export async function GET( + request: NextRequest, + { params }: { params: { projectId: string; fileId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const context: FileAccessContext = { + userId: session.user.id, + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + const fileService = new FileService(); + const hasAccess = await fileService.checkFileAccess( + params.fileId, + context, + 'view' + ); + + if (!hasAccess) { + return NextResponse.json( + { error: '파일 접근 권한이 없습니다' }, + { status: 403 } + ); + } + + // 파일 정보 반환 + const file = await fileService.downloadFile(params.fileId, context); + + if (!file) { + return NextResponse.json( + { error: '파일을 찾을 수 없습니다' }, + { status: 404 } + ); + } + + return NextResponse.json(file); + } catch (error) { + console.error('파일 조회 오류:', error); + return NextResponse.json( + { error: '파일 조회에 실패했습니다' }, + { status: 500 } + ); + } +} + +// 파일 수정 +export async function PATCH( + request: NextRequest, + { params }: { params: { projectId: string; fileId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const context: FileAccessContext = { + userId: session.user.id, + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + const fileService = new FileService(); + const hasAccess = await fileService.checkFileAccess( + params.fileId, + context, + 'edit' + ); + + if (!hasAccess) { + return NextResponse.json( + { error: '파일 수정 권한이 없습니다' }, + { status: 403 } + ); + } + + const body = await request.json(); + + // 파일 이동 처리 + if (body.parentId !== undefined) { + await fileService.moveFile(params.fileId, body.parentId, context); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('파일 수정 오류:', error); + return NextResponse.json( + { error: '파일 수정에 실패했습니다' }, + { status: 500 } + ); + } +} + +// 파일 삭제 +export async function DELETE( + request: NextRequest, + { params }: { params: { projectId: string; fileId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const context: FileAccessContext = { + userId: session.user.id, + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + const fileService = new FileService(); + await fileService.deleteFile(params.fileId, context); + + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof Error && error.message.includes('권한')) { + return NextResponse.json( + { error: error.message }, + { status: 403 } + ); + } + + console.error('파일 삭제 오류:', error); + return NextResponse.json( + { error: '파일 삭제에 실패했습니다' }, + { status: 500 } + ); + } +}
\ No newline at end of file diff --git a/app/api/data-room/[projectId]/download-folder/[folderId]/route.ts b/app/api/data-room/[projectId]/download-folder/[folderId]/route.ts new file mode 100644 index 00000000..bba7066f --- /dev/null +++ b/app/api/data-room/[projectId]/download-folder/[folderId]/route.ts @@ -0,0 +1,289 @@ +// app/api/data-room/[projectId]/download-folder/[folderId]/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { FileService, type FileAccessContext } from '@/lib/services/fileService'; +import { promises as fs } from 'fs'; +import path from 'path'; +import archiver from 'archiver'; +import db from "@/db/db"; +import { fileItems } from "@/db/schema/fileSystem"; +import { eq, and } from "drizzle-orm"; + +interface FileWithPath { + file: any; + absolutePath: string; + relativePath: string; +} + +export async function GET( + request: NextRequest, + { params }: { params: { projectId: string; folderId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const context: FileAccessContext = { + userId: Number(session.user.id), + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + // 폴더 정보 가져오기 + const folder = await db.query.fileItems.findFirst({ + where: and( + eq(fileItems.id, params.folderId), + eq(fileItems.projectId, params.projectId) + ), + }); + + if (!folder || folder.type !== 'folder') { + return NextResponse.json( + { error: '폴더를 찾을 수 없습니다' }, + { status: 404 } + ); + } + + const fileService = new FileService(); + const downloadableFiles: FileWithPath[] = []; + const unauthorizedFiles: string[] = []; + + // 재귀적으로 폴더 내 모든 파일 가져오기 및 권한 확인 + const processFolder = async ( + folderId: string, + folderPath: string = '' + ): Promise<void> => { + const items = await db.query.fileItems.findMany({ + where: and( + eq(fileItems.parentId, folderId), + eq(fileItems.projectId, params.projectId) + ), + }); + + for (const item of items) { + if (item.type === 'file') { + // 파일 권한 확인 + const hasAccess = await fileService.checkFileAccess( + item.id, + context, + 'download' + ); + + if (!hasAccess) { + // 권한이 없는 파일 기록 + unauthorizedFiles.push(path.join(folderPath, item.name)); + continue; + } + + if (!item.filePath) continue; + + // 실제 파일 경로 구성 + const nasPath = process.env.NAS_PATH || "/evcp_nas"; + const isProduction = process.env.NODE_ENV === "production"; + + let absolutePath: string; + if (isProduction) { + const relativePath = item.filePath.replace('/api/files/', ''); + absolutePath = path.join(nasPath, relativePath); + } else { + absolutePath = path.join(process.cwd(), 'public', item.filePath); + } + + // 파일 존재 여부 확인 + try { + await fs.access(absolutePath); + downloadableFiles.push({ + file: item, + absolutePath, + relativePath: path.join(folderPath, item.name) + }); + + // 다운로드 카운트 증가 및 로그 기록 + await fileService.downloadFile(item.id, context); + } catch (error) { + console.warn(`파일을 찾을 수 없습니다: ${absolutePath}`); + } + } else if (item.type === 'folder') { + // 하위 폴더 재귀 처리 + await processFolder( + item.id, + path.join(folderPath, item.name) + ); + } + } + }; + + // 폴더 처리 시작 + await processFolder(params.folderId, folder.name); + + // 권한이 없는 파일이 있으면 다운로드 차단 + if (unauthorizedFiles.length > 0) { + return NextResponse.json( + { + error: '일부 파일에 대한 다운로드 권한이 없습니다', + unauthorizedFiles: unauthorizedFiles, + unauthorizedCount: unauthorizedFiles.length, + message: `다음 파일들에 대한 권한이 없어 폴더 다운로드가 취소되었습니다: ${unauthorizedFiles.slice(0, 5).join(', ')}${unauthorizedFiles.length > 5 ? ` 외 ${unauthorizedFiles.length - 5}개` : ''}` + }, + { status: 403 } + ); + } + + // 다운로드할 파일이 없는 경우 + if (downloadableFiles.length === 0) { + return NextResponse.json( + { error: '다운로드 가능한 파일이 없습니다' }, + { status: 404 } + ); + } + + // 파일 크기 합계 체크 (최대 500MB) + const totalSize = downloadableFiles.reduce((sum, item) => + sum + (item.file.size || 0), 0 + ); + + const maxSize = 500 * 1024 * 1024; // 500MB + if (totalSize > maxSize) { + return NextResponse.json( + { + error: `폴더 크기가 너무 큽니다 (${(totalSize / 1024 / 1024).toFixed(2)}MB). 최대 500MB까지 다운로드 가능합니다.`, + totalSize: totalSize, + maxSize: maxSize, + fileCount: downloadableFiles.length + }, + { status: 400 } + ); + } + + console.log(`📦 폴더 다운로드 시작: ${folder.name} (${downloadableFiles.length}개 파일, ${(totalSize / 1024 / 1024).toFixed(2)}MB)`); + + // ZIP 스트림 생성 + const archive = archiver('zip', { + zlib: { level: 5 } // 압축 레벨 + }); + + // 스트림을 Response로 변환 + const stream = new ReadableStream({ + start(controller) { + archive.on('data', (chunk) => controller.enqueue(chunk)); + archive.on('end', () => controller.close()); + archive.on('error', (err) => { + console.error('Archive error:', err); + controller.error(err); + }); + }, + }); + + // 파일들을 ZIP에 추가 (폴더 구조 유지) + for (const { file, absolutePath, relativePath } of downloadableFiles) { + try { + const fileBuffer = await fs.readFile(absolutePath); + archive.append(fileBuffer, { name: relativePath }); + } catch (error) { + console.error(`파일 추가 실패: ${relativePath}`, error); + } + } + + // ZIP 완료 + archive.finalize(); + + // Response Headers 설정 + const headers = new Headers(); + headers.set('Content-Type', 'application/zip'); + headers.set('Content-Disposition', `attachment; filename="${encodeURIComponent(folder.name)}.zip"`); + headers.set('Cache-Control', 'no-cache, no-store, must-revalidate'); + headers.set('Pragma', 'no-cache'); + headers.set('Expires', '0'); + headers.set('X-File-Count', downloadableFiles.length.toString()); + headers.set('X-Total-Size', totalSize.toString()); + + return new NextResponse(stream, { + status: 200, + headers, + }); + + } catch (error) { + console.error('폴더 다운로드 오류:', error); + return NextResponse.json( + { error: '폴더 다운로드에 실패했습니다' }, + { status: 500 } + ); + } +} + +// 폴더 다운로드 전 권한 체크 (선택적) +export async function HEAD( + request: NextRequest, + { params }: { params: { projectId: string; folderId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return new NextResponse(null, { status: 401 }); + } + + const context: FileAccessContext = { + userId: Number(session.user.id), + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + const fileService = new FileService(); + let totalFiles = 0; + let unauthorizedCount = 0; + let totalSize = 0; + + // 재귀적으로 권한 체크 + const checkFolder = async (folderId: string): Promise<void> => { + const items = await db.query.fileItems.findMany({ + where: and( + eq(fileItems.parentId, folderId), + eq(fileItems.projectId, params.projectId) + ), + }); + + for (const item of items) { + if (item.type === 'file') { + totalFiles++; + totalSize += item.size || 0; + + const hasAccess = await fileService.checkFileAccess( + item.id, + context, + 'download' + ); + + if (!hasAccess) { + unauthorizedCount++; + } + } else if (item.type === 'folder') { + await checkFolder(item.id); + } + } + }; + + await checkFolder(params.folderId); + + const headers = new Headers(); + headers.set('X-Total-Files', totalFiles.toString()); + headers.set('X-Unauthorized-Files', unauthorizedCount.toString()); + headers.set('X-Total-Size', totalSize.toString()); + headers.set('X-Can-Download', unauthorizedCount === 0 ? 'true' : 'false'); + + return new NextResponse(null, { + status: unauthorizedCount > 0 ? 403 : 200, + headers, + }); + + } catch (error) { + console.error('권한 체크 오류:', error); + return new NextResponse(null, { status: 500 }); + } +}
\ No newline at end of file diff --git a/app/api/data-room/[projectId]/download-multiple/route.ts b/app/api/data-room/[projectId]/download-multiple/route.ts new file mode 100644 index 00000000..64c87b55 --- /dev/null +++ b/app/api/data-room/[projectId]/download-multiple/route.ts @@ -0,0 +1,162 @@ +// app/api/data-room/[projectId]/download-multiple/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { FileService, type FileAccessContext } from '@/lib/services/fileService'; +import { promises as fs } from 'fs'; +import path from 'path'; +import archiver from 'archiver'; +import { Readable } from 'stream'; +import db from "@/db/db"; +import { fileItems } from "@/db/schema/fileSystem"; +import { eq, inArray } from "drizzle-orm"; + +export async function POST( + request: NextRequest, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const body = await request.json(); + const { fileIds } = body; + + if (!fileIds || !Array.isArray(fileIds) || fileIds.length === 0) { + return NextResponse.json( + { error: '파일 ID가 제공되지 않았습니다' }, + { status: 400 } + ); + } + + // 너무 많은 파일 방지 (최대 100개) + if (fileIds.length > 100) { + return NextResponse.json( + { error: '한 번에 최대 100개의 파일만 다운로드할 수 있습니다' }, + { status: 400 } + ); + } + + const context: FileAccessContext = { + userId: Number(session.user.id), + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + const fileService = new FileService(); + const downloadableFiles: Array<{ + file: any; + absolutePath: string; + }> = []; + + // 각 파일의 접근 권한 확인 및 경로 확인 + for (const fileId of fileIds) { + // 권한 확인 + const hasAccess = await fileService.checkFileAccess( + fileId, + context, + 'download' + ); + + if (!hasAccess) { + console.warn(`파일 ${fileId}에 대한 다운로드 권한이 없습니다`); + continue; + } + + // 파일 정보 가져오기 + const file = await db.query.fileItems.findFirst({ + where: eq(fileItems.id, fileId), + }); + + if (!file || !file.filePath || file.type !== 'file') { + console.warn(`파일 ${fileId}를 찾을 수 없거나 폴더입니다`); + continue; + } + + // 실제 파일 경로 구성 + const nasPath = process.env.NAS_PATH || "/evcp_nas"; + const isProduction = process.env.NODE_ENV === "production"; + + let absolutePath: string; + if (isProduction) { + const relativePath = file.filePath.replace('/api/files/', ''); + absolutePath = path.join(nasPath, relativePath); + } else { + absolutePath = path.join(process.cwd(), 'public', file.filePath); + } + + // 파일 존재 여부 확인 + try { + await fs.access(absolutePath); + downloadableFiles.push({ file, absolutePath }); + + // 다운로드 카운트 증가 및 로그 기록 + await fileService.downloadFile(fileId, context); + } catch (error) { + console.warn(`파일 ${absolutePath}를 찾을 수 없습니다`); + } + } + + if (downloadableFiles.length === 0) { + return NextResponse.json( + { error: '다운로드 가능한 파일이 없습니다' }, + { status: 404 } + ); + } + + // ZIP 스트림 생성 + const archive = archiver('zip', { + zlib: { level: 5 } // 압축 레벨 (1-9, 5가 균형적) + }); + + // 스트림을 Response로 변환 + const stream = new ReadableStream({ + start(controller) { + archive.on('data', (chunk) => controller.enqueue(chunk)); + archive.on('end', () => controller.close()); + archive.on('error', (err) => controller.error(err)); + }, + }); + + // 파일들을 ZIP에 추가 + for (const { file, absolutePath } of downloadableFiles) { + try { + const fileBuffer = await fs.readFile(absolutePath); + + // 파일명 중복 방지를 위한 고유 이름 생성 + const uniqueName = `${path.parse(file.name).name}_${file.id.slice(0, 8)}${path.extname(file.name)}`; + + archive.append(fileBuffer, { name: uniqueName }); + } catch (error) { + console.error(`파일 추가 실패: ${file.name}`, error); + } + } + + // ZIP 완료 + archive.finalize(); + + // Response Headers 설정 + const headers = new Headers(); + headers.set('Content-Type', 'application/zip'); + headers.set('Content-Disposition', 'attachment; filename="files.zip"'); + headers.set('Cache-Control', 'no-cache, no-store, must-revalidate'); + headers.set('Pragma', 'no-cache'); + headers.set('Expires', '0'); + + return new NextResponse(stream, { + status: 200, + headers, + }); + + } catch (error) { + console.error('다중 파일 다운로드 오류:', error); + return NextResponse.json( + { error: '다중 파일 다운로드에 실패했습니다' }, + { status: 500 } + ); + } +} diff --git a/app/api/data-room/[projectId]/permissions/route.ts b/app/api/data-room/[projectId]/permissions/route.ts new file mode 100644 index 00000000..94401826 --- /dev/null +++ b/app/api/data-room/[projectId]/permissions/route.ts @@ -0,0 +1,74 @@ +// app/api/files/[projectId]/permissions/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route' +import { FileService, type FileAccessContext } from '@/lib/services/fileService'; +import { z } from 'zod'; + +const grantPermissionSchema = z.object({ + fileId: z.string().uuid(), + targetUserId: z.number().optional().nullable(), + targetDomain: z.string().optional().nullable(), + permissions: z.object({ + canView: z.boolean().optional(), + canDownload: z.boolean().optional(), + canEdit: z.boolean().optional(), + canDelete: z.boolean().optional(), + canShare: z.boolean().optional(), + }), +}); + +// 권한 부여 +export async function POST( + request: NextRequest, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const body = await request.json(); + const validatedData = grantPermissionSchema.parse(body); + + const context: FileAccessContext = { + userId: session.user.id, + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + const fileService = new FileService(); + await fileService.grantPermission( + validatedData.fileId, + validatedData.targetUserId, + validatedData.targetDomain, + validatedData.permissions, + context + ); + + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: '잘못된 요청 데이터', details: error.errors }, + { status: 400 } + ); + } + + if (error instanceof Error && error.message.includes('권한')) { + return NextResponse.json( + { error: error.message }, + { status: 403 } + ); + } + + console.error('권한 부여 오류:', error); + return NextResponse.json( + { error: '권한 부여에 실패했습니다' }, + { status: 500 } + ); + } +} diff --git a/app/api/data-room/[projectId]/route.ts b/app/api/data-room/[projectId]/route.ts new file mode 100644 index 00000000..643dcf0f --- /dev/null +++ b/app/api/data-room/[projectId]/route.ts @@ -0,0 +1,118 @@ +// app/api/data-room/[projectId]/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route' +import { FileService, type FileAccessContext } from '@/lib/services/fileService'; +import { z } from 'zod'; + +// 파일 생성 스키마 검증 +const createFileSchema = z.object({ + name: z.string().min(1).max(255), + type: z.enum(['file', 'folder']), + parentId: z.string().uuid().optional().nullable(), + category: z.enum(['public', 'restricted', 'confidential', 'internal']).default('confidential'), + mimeType: z.string().optional(), + size: z.number().optional(), + filePath: z.string().optional(), +}); + +// 파일 목록 조회 +export async function GET( + request: NextRequest, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const searchParams = request.nextUrl.searchParams; + const parentId = searchParams.get('parentId'); + const viewMode = searchParams.get('viewMode'); // 'tree' or 'grid' + const includeAll = searchParams.get('includeAll') === 'true'; // 전체 목록 가져오기 + + const context: FileAccessContext = { + userId: Number(session.user.id), + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + const fileService = new FileService(); + + // viewMode가 tree이거나 includeAll이 true인 경우 전체 목록 가져오기 + const files = await fileService.getFileList( + params.projectId, + parentId, + context, + { + includeAll: viewMode === 'tree' || includeAll + } + ); + + return NextResponse.json(files); + } catch (error) { + console.error('파일 목록 조회 오류:', error); + return NextResponse.json( + { error: '파일 목록을 불러올 수 없습니다' }, + { status: 500 } + ); + } +} + +// 파일/폴더 생성 +export async function POST( + request: NextRequest, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const body = await request.json(); + const validatedData = createFileSchema.parse(body); + + const context: FileAccessContext = { + userId: Number(session.user.id), + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + const fileService = new FileService(); + const newFile = await fileService.createFileItem( + { + ...validatedData, + projectId: params.projectId, + }, + context + ); + + return NextResponse.json(newFile, { status: 201 }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: '잘못된 요청 데이터', details: error.errors }, + { status: 400 } + ); + } + + if (error instanceof Error && error.message === '권한이 없습니다') { + return NextResponse.json( + { error: '파일 생성 권한이 없습니다' }, + { status: 403 } + ); + } + + console.error('파일 생성 오류:', error); + return NextResponse.json( + { error: '파일 생성에 실패했습니다' }, + { status: 500 } + ); + } +}
\ No newline at end of file diff --git a/app/api/data-room/[projectId]/share/[token]/route.ts b/app/api/data-room/[projectId]/share/[token]/route.ts new file mode 100644 index 00000000..51582bca --- /dev/null +++ b/app/api/data-room/[projectId]/share/[token]/route.ts @@ -0,0 +1,45 @@ +// app/api/shared/[token]/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { FileService } from '@/lib/services/fileService'; + +// 공유 링크로 파일 접근 +export async function GET( + request: NextRequest, + { params }: { params: { token: string } } +) { + try { + const searchParams = request.nextUrl.searchParams; + const password = searchParams.get('password'); + + const fileService = new FileService(); + const result = await fileService.accessFileByShareToken( + params.token, + password || undefined + ); + + if (!result) { + return NextResponse.json( + { error: '유효하지 않은 공유 링크입니다' }, + { status: 404 } + ); + } + + return NextResponse.json({ + file: result.file, + accessLevel: result.accessLevel, + }); + } catch (error) { + if (error instanceof Error) { + return NextResponse.json( + { error: error.message }, + { status: 400 } + ); + } + + console.error('공유 파일 접근 오류:', error); + return NextResponse.json( + { error: '파일 접근에 실패했습니다' }, + { status: 500 } + ); + } +} diff --git a/app/api/data-room/[projectId]/share/route.ts b/app/api/data-room/[projectId]/share/route.ts new file mode 100644 index 00000000..9b27d9fc --- /dev/null +++ b/app/api/data-room/[projectId]/share/route.ts @@ -0,0 +1,79 @@ +// app/api/files/[projectId]/share/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route' +import { FileService, type FileAccessContext } from '@/lib/services/fileService'; +import { z } from 'zod'; + +const createShareSchema = z.object({ + fileId: z.string().uuid(), + accessLevel: z.enum(['view_only', 'view_download']).optional(), + password: z.string().optional(), + expiresAt: z.string().datetime().optional(), + maxDownloads: z.number().positive().optional(), + sharedWithEmail: z.string().email().optional(), +}); + +// 공유 링크 생성 +export async function POST( + request: NextRequest, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const body = await request.json(); + const validatedData = createShareSchema.parse(body); + + const context: FileAccessContext = { + userId: session.user.id, + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + const fileService = new FileService(); + const shareToken = await fileService.createShareLink( + validatedData.fileId, + { + ...validatedData, + expiresAt: validatedData.expiresAt + ? new Date(validatedData.expiresAt) + : undefined, + }, + context + ); + + const shareUrl = `${process.env.NEXT_PUBLIC_APP_URL}/shared/${shareToken}`; + + return NextResponse.json({ + shareToken, + shareUrl, + success: true + }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: '잘못된 요청 데이터', details: error.errors }, + { status: 400 } + ); + } + + if (error instanceof Error && error.message.includes('권한')) { + return NextResponse.json( + { error: error.message }, + { status: 403 } + ); + } + + console.error('공유 링크 생성 오류:', error); + return NextResponse.json( + { error: '공유 링크 생성에 실패했습니다' }, + { status: 500 } + ); + } +}
\ No newline at end of file diff --git a/app/api/data-room/[projectId]/upload/route.ts b/app/api/data-room/[projectId]/upload/route.ts new file mode 100644 index 00000000..60bbc10f --- /dev/null +++ b/app/api/data-room/[projectId]/upload/route.ts @@ -0,0 +1,139 @@ +// app/api/data-room/[projectId]/upload/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { FileService, type FileAccessContext } from '@/lib/services/fileService'; +import { saveDRMFile, saveFileStream } from '@/lib/file-stroage'; + +export async function POST( + request: NextRequest, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const context: FileAccessContext = { + userId: Number(session.user.id), + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + // 내부 사용자만 업로드 가능 + if (session.user.domain === 'partners') { + return NextResponse.json( + { error: '파일 업로드 권한이 없습니다' }, + { status: 403 } + ); + } + + const formData = await request.formData(); + const file = formData.get('file') as File; + const category = formData.get('category') as string; + const parentId = formData.get('parentId') as string | null; + const fileSize = formData.get('fileSize') as string | null; + + if (!file) { + return NextResponse.json( + { error: '파일이 제공되지 않았습니다' }, + { status: 400 } + ); + } + + // 대용량 파일 임계값 (10MB) + const LARGE_FILE_THRESHOLD = 10 * 1024 * 1024; + const actualFileSize = fileSize ? parseInt(fileSize) : file.size; + + let result; + + // 파일 크기에 따라 다른 저장 방법 사용 + if (actualFileSize > LARGE_FILE_THRESHOLD) { + console.log(`🚀 대용량 파일 스트리밍 저장: ${file.name} (${(actualFileSize / 1024 / 1024).toFixed(2)}MB)`); + + // 대용량 파일은 스트리밍 저장 사용 + result = await saveFileStream({ + file, + directory: `projects/${params.projectId}`, + originalName: file.name, + userId: session.user.id + }); + } else { + console.log(`📦 일반 파일 저장: ${file.name} (${(actualFileSize / 1024 / 1024).toFixed(2)}MB)`); + + // 작은 파일은 기존 DRM 저장 방식 사용 + result = await saveDRMFile( + file, + async (file) => file.arrayBuffer(), // 이미 복호화된 데이터 + `projects/${params.projectId}`, + session.user.id + ); + } + + if (!result.success) { + return NextResponse.json( + { error: result.error || '파일 저장에 실패했습니다' }, + { status: 500 } + ); + } + + // DB에 파일 정보 저장 + const fileService = new FileService(); + const newFile = await fileService.createFileItem( + { + name: result.originalName || file.name, + type: 'file', + category: category as 'public' | 'restricted' | 'confidential' | 'internal', + parentId, + size: result.fileSize || actualFileSize, + mimeType: file.type, + filePath: result.publicPath, + projectId: params.projectId, + }, + context + ); + + return NextResponse.json({ + ...newFile, + uploadResult: { + ...result, + uploadMethod: actualFileSize > LARGE_FILE_THRESHOLD ? 'stream' : 'buffer', + fileSizeMB: (actualFileSize / 1024 / 1024).toFixed(2) + }, + }, { status: 201 }); + + } catch (error) { + console.error('파일 업로드 오류:', error); + return NextResponse.json( + { + error: '파일 업로드에 실패했습니다', + details: error instanceof Error ? error.message : undefined + }, + { status: 500 } + ); + } +} + +// 업로드 진행률 확인 (선택적) +export async function GET( + request: NextRequest, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + // 업로드 상태 확인 로직 (필요시 구현) + return NextResponse.json({ message: '업로드 상태 확인 엔드포인트' }); + } catch (error) { + return NextResponse.json( + { error: '상태 확인 실패' }, + { status: 500 } + ); + } +}
\ No newline at end of file diff --git a/app/api/files/[...path]/route.ts b/app/api/files/[...path]/route.ts index 88211f5b..89f00a3c 100644 --- a/app/api/files/[...path]/route.ts +++ b/app/api/files/[...path]/route.ts @@ -51,7 +51,8 @@ const isAllowedPath = (requestedPath: string): boolean => { 'vendors', 'pq', 'pq/vendor', - 'information' + 'information', + 'general-contract-templates' ]; return allowedPaths.some(allowed => diff --git a/app/api/partners/rfq-last/[id]/response/route.ts b/app/api/partners/rfq-last/[id]/response/route.ts index 1fc9d5dd..21a4e7a4 100644 --- a/app/api/partners/rfq-last/[id]/response/route.ts +++ b/app/api/partners/rfq-last/[id]/response/route.ts @@ -264,6 +264,8 @@ export async function PUT( vendorDeliveryDate: data.vendorDeliveryDate ? new Date(data.vendorDeliveryDate) : null, submittedAt: data.submittedAt ? new Date(data.submittedAt) : null, responseVersion: existingResponse.responseVersion + 1, + status:"제출완료", + participationStatus: "참여", isLatest: true, createdBy: existingResponse.createdBy, updatedBy: session.user.id, @@ -286,7 +288,9 @@ export async function PUT( // 3. 견적 아이템 업데이트 // 기존 아이템 삭제 await tx.delete(rfqLastVendorQuotationItems) - .where(eq(rfqLastVendorQuotationItems.vendorResponseId, responseId)) + .where(eq(rfqLastVendorQuotationItems.vendorResponseId, existingResponse.id)) + + console.log(data.quotationItems,"data.quotationItems") // 새 아이템 추가 if (data.quotationItems && data.quotationItems.length > 0) { diff --git a/app/api/projects/[projectId]/access/route.ts b/app/api/projects/[projectId]/access/route.ts new file mode 100644 index 00000000..c4b32ca8 --- /dev/null +++ b/app/api/projects/[projectId]/access/route.ts @@ -0,0 +1,36 @@ +// app/api/projects/[projectId]/access/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route' +import { ProjectService } from '@/lib/services/projectService'; + +// 프로젝트 접근 권한 확인 +export async function GET( + request: NextRequest, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const projectService = new ProjectService(); + const access = await projectService.checkProjectAccess( + params.projectId, + Number(session.user.id) + ); + + return NextResponse.json({ + hasAccess: access.hasAccess, + role: access.role || 'viewer', + isOwner: access.isOwner, + }); + } catch (error) { + console.error('권한 확인 오류:', error); + return NextResponse.json( + { hasAccess: false, role: 'viewer', isOwner: false }, + { status: 500 } + ); + } +} diff --git a/app/api/projects/[projectId]/members/[memberId]/route.ts b/app/api/projects/[projectId]/members/[memberId]/route.ts new file mode 100644 index 00000000..55816661 --- /dev/null +++ b/app/api/projects/[projectId]/members/[memberId]/route.ts @@ -0,0 +1,89 @@ +// app/api/projects/[projectId]/members/[memberId]/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route' +import { ProjectService } from '@/lib/services/projectService'; + +// 멤버 역할 수정 +export async function PATCH( + request: NextRequest, + { params }: { params: { projectId: string; memberId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const { role } = await request.json(); + const projectService = new ProjectService(); + + // Owner 또는 Admin만 가능 + const access = await projectService.checkProjectAccess( + params.projectId, + session.user.id, + 'admin' + ); + + if (!access.hasAccess && !access.isOwner) { + return NextResponse.json( + { error: '멤버 역할을 변경할 권한이 없습니다' }, + { status: 403 } + ); + } + + // 멤버 역할 업데이트 + await projectService.updateMemberRole( + params.projectId, + params.memberId, + role + ); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('멤버 역할 변경 오류:', error); + return NextResponse.json( + { error: '역할 변경에 실패했습니다' }, + { status: 500 } + ); + } +} + +// 멤버 제거 +export async function DELETE( + request: NextRequest, + { params }: { params: { projectId: string; memberId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const projectService = new ProjectService(); + + // Owner만 멤버 제거 가능 + const isOwner = await projectService.isProjectOwner( + params.projectId, + session.user.id + ); + + if (!isOwner) { + return NextResponse.json( + { error: '멤버를 제거할 권한이 없습니다' }, + { status: 403 } + ); + } + + // 멤버 제거 + await projectService.removeMember(params.projectId, params.memberId); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('멤버 제거 오류:', error); + return NextResponse.json( + { error: '멤버 제거에 실패했습니다' }, + { status: 500 } + ); + } +}
\ No newline at end of file diff --git a/app/api/projects/[projectId]/members/route.ts b/app/api/projects/[projectId]/members/route.ts new file mode 100644 index 00000000..d24b61e3 --- /dev/null +++ b/app/api/projects/[projectId]/members/route.ts @@ -0,0 +1,76 @@ +// app/api/projects/[projectId]/members/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route' +import { ProjectService } from '@/lib/services/projectService'; + +// 프로젝트 멤버 추가 (Owner만 가능) +export async function POST( + request: NextRequest, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const body = await request.json(); + const projectService = new ProjectService(); + + await projectService.addProjectMember( + params.projectId, + body.userId, + body.role, + Number(session.user.id) + ); + + return NextResponse.json({ success: true }); + } catch (error: any) { + if (error.message.includes('소유자')) { + return NextResponse.json( + { error: error.message }, + { status: 403 } + ); + } + + console.error('멤버 추가 오류:', error); + return NextResponse.json( + { error: '멤버 추가에 실패했습니다' }, + { status: 500 } + ); + } +} + +export async function GET( + request: NextRequest, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const projectService = new ProjectService(); + + const member = await projectService.getProjectMembers( + params.projectId, + ); + + return NextResponse.json({member}); + } catch (error: any) { + if (error.message.includes('소유자')) { + return NextResponse.json( + { error: error.message }, + { status: 403 } + ); + } + + console.error('멤버 조회 오류:', error); + return NextResponse.json( + { error: '멤버 조회에 실패했습니다' }, + { status: 500 } + ); + } +}
\ No newline at end of file diff --git a/app/api/projects/[projectId]/route.ts b/app/api/projects/[projectId]/route.ts new file mode 100644 index 00000000..38c11930 --- /dev/null +++ b/app/api/projects/[projectId]/route.ts @@ -0,0 +1,134 @@ +// app/api/projects/[projectId]/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route' +import { ProjectService } from '@/lib/services/projectService'; +import { z } from 'zod'; + +// GET: 프로젝트 정보 조회 +export async function GET( + request: NextRequest, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const projectService = new ProjectService(); + + // 프로젝트 접근 권한 확인 + const access = await projectService.checkProjectAccess( + params.projectId, + Number(session.user.id) + ); + + if (!access.hasAccess) { + return NextResponse.json( + { error: '프로젝트에 접근할 수 없습니다' }, + { status: 403 } + ); + } + + // 프로젝트 정보 가져오기 + const project = await projectService.getProject(params.projectId); + + if (!project) { + return NextResponse.json( + { error: '프로젝트를 찾을 수 없습니다' }, + { status: 404 } + ); + } + + // 사용자의 역할과 함께 프로젝트 정보 반환 + return NextResponse.json({ + ...project, + role: access.role, + isOwner: access.isOwner, + }); + } catch (error) { + console.error('프로젝트 조회 오류:', error); + return NextResponse.json( + { error: '프로젝트 정보를 불러올 수 없습니다' }, + { status: 500 } + ); + } +} + +// PATCH: 프로젝트 정보 수정 +export async function PATCH( + request: NextRequest, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const body = await request.json(); + const projectService = new ProjectService(); + + // Admin 이상 권한 확인 + const access = await projectService.checkProjectAccess( + params.projectId, + Number(session.user.id), + 'admin' + ); + + if (!access.hasAccess) { + return NextResponse.json( + { error: '프로젝트를 수정할 권한이 없습니다' }, + { status: 403 } + ); + } + + await projectService.updateProjectSettings( + params.projectId, + Number(session.user.id), + body + ); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('프로젝트 수정 오류:', error); + return NextResponse.json( + { error: '프로젝트 수정에 실패했습니다' }, + { status: 500 } + ); + } +} + +// DELETE: 프로젝트 삭제 +export async function DELETE( + request: NextRequest, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const projectService = new ProjectService(); + + // Owner만 삭제 가능 + await projectService.deleteProject(params.projectId, session.user.id); + + return NextResponse.json({ success: true }); + } catch (error: any) { + if (error.message.includes('소유자')) { + return NextResponse.json( + { error: error.message }, + { status: 403 } + ); + } + + console.error('프로젝트 삭제 오류:', error); + return NextResponse.json( + { error: '프로젝트 삭제에 실패했습니다' }, + { status: 500 } + ); + } +}
\ No newline at end of file diff --git a/app/api/projects/[projectId]/stats/route.ts b/app/api/projects/[projectId]/stats/route.ts new file mode 100644 index 00000000..dc2397ac --- /dev/null +++ b/app/api/projects/[projectId]/stats/route.ts @@ -0,0 +1,275 @@ +// app/api/fileSystemProjects/[projectId]/stats/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import db from "@/db/db"; +import { fileItems, fileActivityLogs, fileSystemProjects, projectMembers } from "@/db/schema"; +import { eq, and, gte, sql, desc } from "drizzle-orm"; + +export async function GET( + request: NextRequest, + context: { params: Promise<{ projectId: string }> } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const params = await context.params; + const projectId = params.projectId; + + // URL 파라미터에서 날짜 범위 가져오기 + const searchParams = request.nextUrl.searchParams; + const range = searchParams.get('range') || '30d'; + + // 날짜 범위 계산 + const now = new Date(); + let startDate = new Date(); + + switch (range) { + case '7d': + startDate.setDate(now.getDate() - 7); + break; + case '30d': + startDate.setDate(now.getDate() - 30); + break; + case '90d': + startDate.setDate(now.getDate() - 90); + break; + default: + startDate.setDate(now.getDate() - 30); + } + + // 이전 기간 (트렌드 계산용) + const previousStartDate = new Date(startDate); + previousStartDate.setDate(previousStartDate.getDate() - (now.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)); + + // 프로젝트 접근 권한 확인 + const projectMember = await db.query.projectMembers.findFirst({ + where: and( + eq(projectMembers.projectId, projectId), + eq(projectMembers.userId, Number(session.user.id)) + ), + }); + + const isInternalUser = session.user.domain !== 'partners'; + + // 내부 사용자가 아니고 프로젝트 멤버가 아닌 경우 접근 거부 + if (!isInternalUser && !projectMember) { + return NextResponse.json( + { error: '통계를 볼 권한이 없습니다' }, + { status: 403 } + ); + } + + // 1. 스토리지 통계 + const storageStats = await db + .select({ + totalSize: sql<number>`COALESCE(SUM(${fileItems.size}), 0)`, + fileCount: sql<number>`COUNT(CASE WHEN ${fileItems.type} = 'file' THEN 1 END)`, + folderCount: sql<number>`COUNT(CASE WHEN ${fileItems.type} = 'folder' THEN 1 END)`, + }) + .from(fileItems) + .where(eq(fileItems.projectId, projectId)); + + // 카테고리별 파일 수 + const categoryStats = await db + .select({ + category: fileItems.category, + count: sql<number>`COUNT(*)`, + }) + .from(fileItems) + .where(and( + eq(fileItems.projectId, projectId), + eq(fileItems.type, 'file') + )) + .groupBy(fileItems.category); + + const byCategory = { + public: 0, + restricted: 0, + confidential: 0, + internal: 0, + }; + + categoryStats.forEach(stat => { + if (stat.category && stat.category in byCategory) { + byCategory[stat.category as keyof typeof byCategory] = Number(stat.count); + } + }); + + // 2. 활동 통계 (현재 기간) + const activityStats = await db + .select({ + action: fileActivityLogs.action, + count: sql<number>`COUNT(*)`, + }) + .from(fileActivityLogs) + .where(and( + eq(fileActivityLogs.projectId, projectId), + gte(fileActivityLogs.createdAt, startDate) + )) + .groupBy(fileActivityLogs.action); + + // 이전 기간 통계 (트렌드 계산용) + const previousActivityStats = await db + .select({ + action: fileActivityLogs.action, + count: sql<number>`COUNT(*)`, + }) + .from(fileActivityLogs) + .where(and( + eq(fileActivityLogs.projectId, projectId), + gte(fileActivityLogs.createdAt, previousStartDate), + sql`${fileActivityLogs.createdAt} < ${startDate}` + )) + .groupBy(fileActivityLogs.action); + + const activityCounts = { + views: 0, + downloads: 0, + uploads: 0, + shares: 0, + }; + + const previousCounts = { + downloads: 0, + }; + + activityStats.forEach(stat => { + switch (stat.action) { + case 'view': + activityCounts.views = Number(stat.count); + break; + case 'download': + activityCounts.downloads = Number(stat.count); + break; + case 'upload': + activityCounts.uploads = Number(stat.count); + break; + case 'share': + activityCounts.shares = Number(stat.count); + break; + } + }); + + previousActivityStats.forEach(stat => { + if (stat.action === 'download') { + previousCounts.downloads = Number(stat.count); + } + }); + + // 트렌드 계산 (다운로드 기준) + const trend = previousCounts.downloads > 0 + ? Math.round(((activityCounts.downloads - previousCounts.downloads) / previousCounts.downloads) * 100) + : 0; + + // 3. 사용자 통계 + const userStats = await db + .select({ + total: sql<number>`COUNT(DISTINCT ${projectMembers.userId})`, + }) + .from(projectMembers) + .where(eq(projectMembers.projectId, projectId)); + + // 활성 사용자 (최근 활동이 있는 사용자) + const activeUsers = await db + .select({ + count: sql<number>`COUNT(DISTINCT ${fileActivityLogs.userId})`, + }) + .from(fileActivityLogs) + .where(and( + eq(fileActivityLogs.projectId, projectId), + gte(fileActivityLogs.createdAt, startDate) + )); + + // 역할별 사용자 수 (간단하게 처리) + const roleStats = await db + .select({ + role: projectMembers.role, + count: sql<number>`COUNT(*)`, + }) + .from(projectMembers) + .where(eq(projectMembers.projectId, projectId)) + .groupBy(projectMembers.role); + + const byRole = { + admin: 0, + editor: 0, + viewer: 0, + }; + + roleStats.forEach(stat => { + if (stat.role === 'manager') byRole.admin = Number(stat.count); + else if (stat.role === 'member') byRole.editor = Number(stat.count); + else byRole.viewer = Number(stat.count); + }); + + // 4. 최근 활동 내역 + const recentActivities = await db + .select({ + action: fileActivityLogs.action, + userEmail: fileActivityLogs.userEmail, + createdAt: fileActivityLogs.createdAt, + fileName: fileItems.name, + fileType: fileItems.type, + }) + .from(fileActivityLogs) + .leftJoin(fileItems, eq(fileActivityLogs.fileItemId, fileItems.id)) + .where(and( + eq(fileActivityLogs.projectId, projectId), + gte(fileActivityLogs.createdAt, startDate) + )) + .orderBy(desc(fileActivityLogs.createdAt)) + .limit(10); + + const recent = recentActivities.map(activity => ({ + type: activity.fileType || 'file', + user: activity.userEmail?.split('@')[0] || 'Unknown', + action: activity.action, + timestamp: activity.createdAt.toISOString(), + details: activity.fileName || 'Unknown file', + })); + + // 5. 프로젝트 정보 (스토리지 제한 등) + const project = await db.query.fileSystemProjects.findFirst({ + where: eq(fileSystemProjects.id, projectId), + }); + + const storageLimit = 10 * 1024 * 1024 * 1024; // 기본 10GB + + // 응답 데이터 구성 + const stats = { + storage: { + used: Number(storageStats[0]?.totalSize || 0), + limit: storageLimit, + fileCount: Number(storageStats[0]?.fileCount || 0), + folderCount: Number(storageStats[0]?.folderCount || 0), + byCategory, + }, + activity: { + views: activityCounts.views, + downloads: activityCounts.downloads, + uploads: activityCounts.uploads, + shares: activityCounts.shares, + trend, + }, + users: { + total: Number(userStats[0]?.total || 0), + active: Number(activeUsers[0]?.count || 0), + byRole, + }, + recent, + }; + + return NextResponse.json(stats); + + } catch (error) { + console.error('통계 조회 오류:', error); + return NextResponse.json( + { error: '통계를 불러올 수 없습니다' }, + { status: 500 } + ); + } +}
\ No newline at end of file diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts new file mode 100644 index 00000000..c64676c6 --- /dev/null +++ b/app/api/projects/route.ts @@ -0,0 +1,56 @@ +// app/api/projects/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route' +import { ProjectService } from '@/lib/services/projectService'; + +// 프로젝트 생성 +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const body = await request.json(); + const projectService = new ProjectService(); + + const project = await projectService.createProject( + { + name: body.name, + description: body.description, + isPublic: body.isPublic || false, + }, + Number(session.user.id) + ); + + return NextResponse.json(project, { status: 201 }); + } catch (error) { + console.error('프로젝트 생성 오류:', error); + return NextResponse.json( + { error: '프로젝트 생성에 실패했습니다' }, + { status: 500 } + ); + } +} + +// 사용자의 프로젝트 목록 조회 +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const projectService = new ProjectService(); + const projects = await projectService.getUserProjects(session.user.id); + + return NextResponse.json(projects); + } catch (error) { + console.error('프로젝트 목록 조회 오류:', error); + return NextResponse.json( + { error: '프로젝트 목록을 불러올 수 없습니다' }, + { status: 500 } + ); + } +}
\ No newline at end of file |
