diff options
Diffstat (limited to 'app/api')
| -rw-r--r-- | app/api/auth/[...nextauth]/route.ts | 111 | ||||
| -rw-r--r-- | app/api/files/[...path]/route.ts | 74 | ||||
| -rw-r--r-- | app/api/rfq-download/route.ts | 121 | ||||
| -rw-r--r-- | app/api/rfq-upload/route.ts | 36 | ||||
| -rw-r--r-- | app/api/upload/route.ts | 38 | ||||
| -rw-r--r-- | app/api/vendors/attachments/download-temp/route.ts | 102 | ||||
| -rw-r--r-- | app/api/vendors/erp/route.ts | 144 |
7 files changed, 626 insertions, 0 deletions
diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 00000000..609a63d7 --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,111 @@ +// (1) next-auth에서 필요한 타입들을 import +import NextAuth, { + NextAuthOptions, // authOptions에 쓸 타입 + Session, + User +} from 'next-auth' +import { JWT } from "next-auth/jwt" + +import CredentialsProvider from 'next-auth/providers/credentials' + +import { verifyOtp } from '@/lib/users/verifyOtp' + +// 1) 모듈 보강 선언 +declare module "next-auth" { + /** + * Session 객체를 확장 + */ + interface Session { + user: { + /** 우리가 필요로 하는 user id */ + id: string + + // 기본적으로 NextAuth가 제공하는 name/email/image 필드 + name?: string | null + email?: string | null + image?: string | null + companyId?: number | null + domain?: string | null + + } + } + + /** + * User 객체를 확장 + */ + interface User { + id: string + imageUrl?: string | null + companyId?: number | null + domain?: string | null + // 필요한 필드를 추가로 선언 가능 + } +} + +// (2) authOptions에 NextAuthOptions 타입 지정 +export const authOptions: NextAuthOptions = { + providers: [ + CredentialsProvider({ + name: 'Credentials', + credentials: { + email: { label: 'Email', type: 'text' }, + code: { label: 'OTP code', type: 'text' }, + }, + async authorize(credentials, req) { + const { email, code } = credentials ?? {} + + // OTP 검증 + const user = await verifyOtp(email ?? '', code ?? '') + if (!user) { + return null + } + + return { + id: String(user.id ?? email ?? "dts"), + email: user.email, + imageUrl: user.imageUrl ?? null, + name: user.name, // DB에서 가져온 실제 이름 + companyId: user.companyId, // DB에서 가져온 실제 이름 + domain: user.domain, // DB에서 가져온 실제 이름 + } + }, + }) + ], + // (3) session.strategy는 'jwt'가 되도록 선언 + // 필요하다면 as SessionStrategy 라고 명시해줄 수도 있음 + // 예) strategy: 'jwt' as SessionStrategy + session: { + strategy: 'jwt', + }, + callbacks: { + // (4) 콜백에서 token, user, session 등의 타입을 좀 더 명시해주고 싶다면 아래처럼 destructuring에 제네릭/타입 지정 + async jwt({ token, user }: { token: JWT; user?: User }) { + if (user) { + token.id = user.id + token.email = user.email + token.name = user.name + token.companyId = user.companyId + token.domain = user.domain + ; (token as any).imageUrl = (user as any).imageUrl + } + return token + }, + async session({ session, token }: { session: Session; token: JWT }) { + if (token) { + session.user = { + id: token.id as string, + email: token.email as string, + name: token.name as string, + domain: token.domain as string, + companyId: token.companyId as number, + image: (token as any).imageUrl ?? null + } + } + return session + }, + }, +} + +const handler = NextAuth(authOptions) + +export { handler as GET, handler as POST }
\ No newline at end of file diff --git a/app/api/files/[...path]/route.ts b/app/api/files/[...path]/route.ts new file mode 100644 index 00000000..f92dd1d8 --- /dev/null +++ b/app/api/files/[...path]/route.ts @@ -0,0 +1,74 @@ +// app/api/files/[...path]/route.ts +import { NextRequest, NextResponse } from 'next/server' +import { readFile } from 'fs/promises' +import { join } from 'path' +import { stat } from 'fs/promises' + +export async function GET( + request: NextRequest, + { params }: { params: { path: string[] } } +) { + try { + + const path = request.nextUrl.searchParams.get("path"); + + + // 경로 파라미터에서 파일 경로 조합 + const filePath = join(process.cwd(), 'uploads', ...params.path) + + // 파일 존재 여부 확인 + try { + await stat(filePath) + } catch (error) { + return NextResponse.json( + { error: 'File not found' }, + { status: 404 } + ) + } + + // 파일 읽기 + const fileBuffer = await readFile(filePath) + + // 파일 확장자에 따른 MIME 타입 설정 + const fileName = params.path[params.path.length - 1] + const fileExtension = fileName.split('.').pop()?.toLowerCase() + + let contentType = 'application/octet-stream' + + if (fileExtension) { + const mimeTypes: Record<string, string> = { + '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', + 'png': 'image/png', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'gif': 'image/gif', + } + + contentType = mimeTypes[fileExtension] || contentType + } + + // 다운로드 설정 + const headers = new Headers() + headers.set('Content-Type', contentType) + headers.set('Content-Disposition', `attachment; filename="${fileName}"`) + + return new NextResponse(fileBuffer, { + status: 200, + headers, + }) + } catch (error) { + console.error('Error downloading file:', error) + return NextResponse.json( + { error: 'Failed to download file' }, + { status: 500 } + ) + } +}
\ No newline at end of file diff --git a/app/api/rfq-download/route.ts b/app/api/rfq-download/route.ts new file mode 100644 index 00000000..19991128 --- /dev/null +++ b/app/api/rfq-download/route.ts @@ -0,0 +1,121 @@ +// app/api/rfq-download/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { readFile, access, constants } from 'fs/promises'; +import { join } from 'path'; +import db from '@/db/db'; +import { rfqAttachments } from '@/db/schema/rfq'; +import { eq } from 'drizzle-orm'; + +export async function GET(request: NextRequest) { + try { + // 파일 경로 파라미터 받기 + const path = request.nextUrl.searchParams.get("path"); + + if (!path) { + return NextResponse.json( + { error: "File path is required" }, + { status: 400 } + ); + } + + // DB에서 파일 정보 조회 (정확히 일치하는 filePath로 검색) + const [dbRecord] = await db + .select({ + fileName: rfqAttachments.fileName, + filePath: rfqAttachments.filePath + }) + .from(rfqAttachments) + .where(eq(rfqAttachments.filePath, path)); + + // 파일 정보 설정 + let fileName; + + if (dbRecord) { + // DB에서 찾은 경우 원본 파일명 사용 + fileName = dbRecord.fileName; + console.log("DB에서 원본 파일명 찾음:", fileName); + } else { + // DB에서 찾지 못한 경우 경로에서 파일명 추출 + fileName = path.split('/').pop() || 'download'; + } + + // 파일 경로 구성 + const storedPath = path.replace(/^\/+/, ""); // 앞쪽 슬래시 제거 + + // 파일 경로 시도 + const possiblePaths = [ + join(process.cwd(), "public", storedPath) + ]; + + // 실제 파일 찾기 + let actualPath = null; + for (const testPath of possiblePaths) { + try { + await access(testPath, constants.R_OK); + actualPath = testPath; + break; + } catch (err) { + console.log("❌ 경로에 파일 없음:", testPath); + } + } + + if (!actualPath) { + return NextResponse.json( + { + error: "File not found on server", + details: { + path: path, + triedPaths: possiblePaths + } + }, + { status: 404 } + ); + } + + const fileBuffer = await readFile(actualPath); + + // MIME 타입 결정 + const fileExtension = fileName.split('.').pop()?.toLowerCase() || ''; + + let contentType = 'application/octet-stream'; // 기본 바이너리 + + // 확장자에 따른 MIME 타입 매핑 + const mimeTypes: Record<string, string> = { + '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', + 'png': 'image/png', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'gif': 'image/gif', + }; + + contentType = mimeTypes[fileExtension] || contentType; + + // 다운로드용 헤더 설정 + const headers = new Headers(); + headers.set('Content-Type', contentType); + headers.set('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"`); + headers.set('Content-Length', fileBuffer.length.toString()); + + return new NextResponse(fileBuffer, { + status: 200, + headers, + }); + } catch (error) { + console.error('❌ RFQ 파일 다운로드 오류:', error); + return NextResponse.json( + { + error: 'Failed to download file', + details: String(error) + }, + { status: 500 } + ); + } +}
\ No newline at end of file diff --git a/app/api/rfq-upload/route.ts b/app/api/rfq-upload/route.ts new file mode 100644 index 00000000..97beafb1 --- /dev/null +++ b/app/api/rfq-upload/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server" +import { promises as fs } from "fs" +import path from "path" +import { v4 as uuidv4 } from "uuid" + +export async function POST(req: NextRequest) { + try { + const formData = await req.formData() + const file = formData.get("file") as File // "file" is default name from FilePond + if (!file) { + return NextResponse.json({ error: "No file" }, { status: 400 }) + } + + // e.g. parse a query param? or read 'rfqId' if we appended it + // const rfqId = ... (FilePond advanced config or handle differently) + + // read file + const arrayBuffer = await file.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + + // unique filename + const uniqueName = uuidv4() + "-" + file.name + const targetDir = path.join(process.cwd(), "public", "rfq", "123") // or your rfqId + await fs.mkdir(targetDir, { recursive: true }) + const targetPath = path.join(targetDir, uniqueName) + + // write + await fs.writeFile(targetPath, buffer) + + // Return success. Typically you'd insert DB record here or return some reference + return NextResponse.json({ success: true, filePath: `/rfq/123/${uniqueName}` }) + } catch (error) { + console.error("upload error:", error) + return NextResponse.json({ error: String(error) }, { status: 500 }) + } +}
\ No newline at end of file diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts new file mode 100644 index 00000000..3b1d8be0 --- /dev/null +++ b/app/api/upload/route.ts @@ -0,0 +1,38 @@ +// app/api/upload/route.ts +import { NextRequest, NextResponse } from "next/server" +import { createWriteStream } from "fs" +import path from "path" +import { v4 as uuid } from "uuid" + +export async function POST(request: NextRequest) { + const formData = await request.formData() + const file = formData.get("file") as File | null + if (!file) { + return NextResponse.json({ error: "No file" }, { status: 400 }) + } + + // 여기서는 로컬 /public/uploads 에 저장한다고 가정 + const fileExt = path.extname(file.name) + const newFileName = `${uuid()}${fileExt}` + const filePath = path.join(process.cwd(), "public", "uploads", newFileName) + + const arrayBuffer = await file.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + + // 로컬에 저장 + await new Promise<void>((resolve, reject) => { + const writeStream = createWriteStream(filePath) + writeStream.write(buffer) + writeStream.end() + writeStream.on("finish", resolve) + writeStream.on("error", reject) + }) + + // /uploads/xxxx.ext 로 접근 가능 + const url = `/uploads/${newFileName}` + return NextResponse.json({ + fileName: file.name, + url, + size: file.size, + }) +}
\ No newline at end of file diff --git a/app/api/vendors/attachments/download-temp/route.ts b/app/api/vendors/attachments/download-temp/route.ts new file mode 100644 index 00000000..987e421d --- /dev/null +++ b/app/api/vendors/attachments/download-temp/route.ts @@ -0,0 +1,102 @@ +// app/api/vendors/attachments/download-temp/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import path from 'path'; +import fs from 'fs'; +import { promises as fsPromises } from 'fs'; +import { cleanupTempFiles } from '@/lib/vendors/service'; + +export async function GET(request: NextRequest) { + try { + // 파일명 파라미터 추출 + const searchParams = request.nextUrl.searchParams; + const fileName = searchParams.get('file'); + + if (!fileName) { + return NextResponse.json( + { success: false, error: 'File name is required' }, + { status: 400 } + ); + } + + // 보안: 파일명에 경로 문자가 포함되어 있는지 확인 (경로 탐색 공격 방지) + if (fileName.includes('/') || fileName.includes('\\')) { + return NextResponse.json( + { success: false, error: 'Invalid file name' }, + { status: 400 } + ); + } + + // 임시 디렉토리의 파일 경로 생성 + const tempDir = path.join(process.cwd(), 'tmp'); + const filePath = path.join(tempDir, fileName); + + // 파일 존재 확인 + try { + await fsPromises.access(filePath, fs.constants.F_OK); + } catch { + return NextResponse.json( + { success: false, error: 'File not found' }, + { status: 404 } + ); + } + + // 파일 읽기 + const fileBuffer = await fsPromises.readFile(filePath); + + // 파일명에서 UUID 부분 제거하여 표시용 이름 생성 + const uuidPattern = /-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.zip$/i; + const displayName = fileName.replace(uuidPattern, '.zip'); + + // 파일 응답 반환 + const response = new NextResponse(fileBuffer, { + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="${encodeURIComponent(displayName)}"`, + }, + }); + + // 비동기적으로 파일 정리 요청 (별도 API 호출) + // Note: Next.js 환경에 따라 작동하지 않을 수 있음 + try { + fetch(`${request.nextUrl.origin}/api/vendors/cleanup-temp-files?file=${encodeURIComponent(fileName)}`, { + method: 'POST', + }).catch(e => console.error('임시 파일 정리 요청 실패:', e)); + } catch (e) { + console.error('파일 정리 요청 오류:', e); + } + + return response; + } catch (error) { + console.error('임시 파일 다운로드 오류:', error); + return NextResponse.json( + { success: false, error: 'Failed to download file' }, + { status: 500 } + ); + } +} + +// 임시 파일 정리 API 엔드포인트 +// app/api/vendors/cleanup-temp-files/route.ts +export async function POST(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + const fileName = searchParams.get('file'); + + if (!fileName) { + return NextResponse.json({ success: false, error: 'File name is required' }, { status: 400 }); + } + + // 보안 검증 + if (fileName.includes('/') || fileName.includes('\\')) { + return NextResponse.json({ success: false, error: 'Invalid file name' }, { status: 400 }); + } + + // 서버 액션 호출하여 파일 정리 + await cleanupTempFiles(fileName); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('임시 파일 정리 API 오류:', error); + return NextResponse.json({ success: false, error: '임시 파일 정리 중 오류가 발생했습니다.' }, { status: 500 }); + } +}
\ No newline at end of file diff --git a/app/api/vendors/erp/route.ts b/app/api/vendors/erp/route.ts new file mode 100644 index 00000000..0724eeeb --- /dev/null +++ b/app/api/vendors/erp/route.ts @@ -0,0 +1,144 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { headers } from 'next/headers'; +import { getErrorMessage } from '@/lib/handle-error'; + +/** + * 기간계 시스템에 벤더 정보를 전송하는 API 엔드포인트 + * 서버 액션 내부에서 호출됨 + */ +export async function POST(request: NextRequest) { + try { + + // 요청 본문 파싱 + const vendorData = await request.json(); + + // 기간계 시스템 API 설정 + const erpApiUrl = process.env.ERP_API_URL; + const erpApiKey = process.env.ERP_API_KEY; + + if (!erpApiUrl || !erpApiKey) { + return NextResponse.json( + { success: false, message: 'ERP API configuration is missing' }, + { status: 500 } + ); + } + + // 기간계 시스템이 요구하는 형식으로 데이터 변환 + const erpRequestData = { + vendor: { + name: vendorData.vendorName, + tax_id: vendorData.taxId, + address: vendorData.address || "", + country: vendorData.country || "", + phone: vendorData.phone || "", + email: vendorData.email || "", + website: vendorData.website || "", + external_id: vendorData.id.toString(), + }, + contacts: vendorData.contacts.map((contact: any) => ({ + name: contact.contactName, + position: contact.contactPosition || "", + email: contact.contactEmail, + phone: contact.contactPhone || "", + is_primary: contact.isPrimary ? 1 : 0, + })), + items: vendorData.possibleItems.map((item: any) => ({ + item_code: item.itemCode, + description: item.description || "", + })), + attachments: vendorData.attachments.map((attach: any) => ({ + file_name: attach.fileName, + file_path: attach.filePath, + })), + }; + + // 기간계 시스템 API 호출 + const response = await fetch(erpApiUrl, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${erpApiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(erpRequestData), + // Next.js의 fetch는 기본 30초 타임아웃 + }); + + // 응답 처리 + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + return NextResponse.json( + { + success: false, + message: `ERP system error: ${response.status} ${response.statusText}`, + details: errorData + }, + { status: 502 } // Bad Gateway (외부 서버 오류) + ); + } + + const result = await response.json(); + + // 벤더 코드 검증 + if (!result.vendor_code) { + return NextResponse.json( + { success: false, message: 'Vendor code not provided in ERP response' }, + { status: 502 } + ); + } + + // 성공 응답 + return NextResponse.json({ + success: true, + vendorCode: result.vendor_code, + message: 'Vendor successfully registered in ERP system', + ...result + }); + } catch (error) { + console.error('Error in ERP API:', error); + return NextResponse.json( + { + success: false, + message: getErrorMessage(error) + }, + { status: 500 } + ); + } +} + +/** + * 기간계 시스템 연결 상태 확인 (헬스 체크) + */ +export async function GET() { + try { + const healthCheckUrl = process.env.ERP_HEALTH_CHECK_URL; + + if (!healthCheckUrl) { + return NextResponse.json( + { success: false, message: 'ERP health check URL not configured' }, + { status: 500 } + ); + } + + const response = await fetch(healthCheckUrl, { + method: 'GET', + next: { revalidate: 60 } // 1분마다 재검증 + }); + + const isAvailable = response.ok; + + return NextResponse.json({ + success: true, + available: isAvailable, + status: response.status, + timestamp: new Date().toISOString() + }); + } catch (error) { + console.error('ERP health check error:', error); + return NextResponse.json({ + success: false, + available: false, + error: getErrorMessage(error), + timestamp: new Date().toISOString() + }); + } +}
\ No newline at end of file |
