summaryrefslogtreecommitdiff
path: root/app/api
diff options
context:
space:
mode:
Diffstat (limited to 'app/api')
-rw-r--r--app/api/auth/[...nextauth]/route.ts111
-rw-r--r--app/api/files/[...path]/route.ts74
-rw-r--r--app/api/rfq-download/route.ts121
-rw-r--r--app/api/rfq-upload/route.ts36
-rw-r--r--app/api/upload/route.ts38
-rw-r--r--app/api/vendors/attachments/download-temp/route.ts102
-rw-r--r--app/api/vendors/erp/route.ts144
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