diff options
Diffstat (limited to 'app/api')
| -rw-r--r-- | app/api/contracts/prepare-template/route.ts | 4 | ||||
| -rw-r--r-- | app/api/document-reviews/[id]/route.ts | 138 | ||||
| -rw-r--r-- | app/api/files/[...path]/route.ts | 38 | ||||
| -rw-r--r-- | app/api/partners/rfq-last/[id]/response/route.ts | 12 | ||||
| -rw-r--r-- | app/api/partners/tbe/[sessionId]/documents/route.ts | 275 | ||||
| -rw-r--r-- | app/api/pdftron-comments/xfdf/count/route.ts | 171 | ||||
| -rw-r--r-- | app/api/pdftron-comments/xfdf/route.ts | 362 | ||||
| -rw-r--r-- | app/api/tbe/sessions/[sessionId]/vendor-questions/route.ts | 131 | ||||
| -rw-r--r-- | app/api/tbe/sessions/[sessionId]/vendor-remarks/route.ts | 57 | ||||
| -rw-r--r-- | app/api/upload/signed-contract/route.ts | 43 |
10 files changed, 1200 insertions, 31 deletions
diff --git a/app/api/contracts/prepare-template/route.ts b/app/api/contracts/prepare-template/route.ts index 189643b5..7d0f39c6 100644 --- a/app/api/contracts/prepare-template/route.ts +++ b/app/api/contracts/prepare-template/route.ts @@ -5,7 +5,7 @@ import { eq, and, ilike } from "drizzle-orm"; export async function POST(request: NextRequest) { try { - const { templateName, vendorId } = await request.json(); + const { templateName, vendorId, biddingId, biddingCompanyId } = await request.json(); // 템플릿 조회 const [template] = await db @@ -65,7 +65,7 @@ export async function POST(request: NextRequest) { business_size: vendor.businessSize || '', credit_rating: vendor.creditRating || '', template_type: templateName, - contract_number: `BC-${new Date().getFullYear()}-${String(vendorId).padStart(4, '0')}-${Date.now()}`, + contract_number: `BC-${new Date().getFullYear()}-${biddingId || '0'}-${String(vendorId).padStart(4, '0')}-${Date.now()}`, }; return NextResponse.json({ diff --git a/app/api/document-reviews/[id]/route.ts b/app/api/document-reviews/[id]/route.ts new file mode 100644 index 00000000..472f93bf --- /dev/null +++ b/app/api/document-reviews/[id]/route.ts @@ -0,0 +1,138 @@ +// app/api/document-reviews/[id]/route.ts + +import { NextRequest, NextResponse } from "next/server" +import db from "@/db/db" +import { rfqLastTbeDocumentReviews } from "@/db/schema" +import { eq } from "drizzle-orm" +import { getServerSession } from "next-auth" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { revalidateTag } from "next/cache" + +// PATCH - 문서 리뷰 업데이트 +export async function PATCH( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions) + if (!session?.user) { + return NextResponse.json({ error: "인증이 필요합니다." }, { status: 401 }) + } + + const reviewId = parseInt(params.id) + if (!reviewId) { + return NextResponse.json({ error: "Invalid review ID" }, { status: 400 }) + } + + const body = await request.json() + const { reviewStatus, reviewComments } = body + + // 현재 문서 리뷰 조회 + const [currentReview] = await db + .select() + .from(rfqLastTbeDocumentReviews) + .where(eq(rfqLastTbeDocumentReviews.id, reviewId)) + .limit(1) + + if (!currentReview) { + return NextResponse.json({ error: "Review not found" }, { status: 404 }) + } + + // 권한 체크 - 구매자만 리뷰 가능 (또는 admin) + const userId = typeof session.user.id === 'string' ? parseInt(session.user.id) : session.user.id + const isAdmin = (session.user as any).roles?.includes('admin') || false + + // 여기서는 구매자 권한 체크를 간단히 처리 + // 실제로는 세션의 role이나 type을 확인해야 함 + + // 업데이트할 데이터 준비 + const updateData: any = { + updatedAt: new Date() + } + + if (reviewStatus !== undefined) { + updateData.reviewStatus = reviewStatus + } + + if (reviewComments !== undefined) { + updateData.reviewComments = reviewComments + } + + // 리뷰 상태가 변경되면 관련 필드도 업데이트 + if (reviewStatus && reviewStatus !== currentReview.reviewStatus) { + updateData.reviewedBy = userId + updateData.reviewedAt = new Date() + + // 상태에 따른 추가 필드 설정 + switch (reviewStatus) { + case "승인": + updateData.technicalCompliance = true + updateData.qualityAcceptable = true + updateData.requiresRevision = false + break + case "반려": + updateData.technicalCompliance = false + updateData.qualityAcceptable = false + updateData.requiresRevision = true + break + case "보류": + updateData.requiresRevision = true + break + } + } + + // 업데이트 실행 + const [updated] = await db + .update(rfqLastTbeDocumentReviews) + .set(updateData) + .where(eq(rfqLastTbeDocumentReviews.id, reviewId)) + .returning() + + // 캐시 초기화 + if (currentReview.tbeSessionId) { + revalidateTag(`tbe-session-${currentReview.tbeSessionId}`) + } + + return NextResponse.json(updated) + } catch (error) { + console.error("Failed to update document review:", error) + return NextResponse.json({ + error: "Failed to update document review" + }, { status: 500 }) + } +} + +// GET - 문서 리뷰 조회 +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions) + if (!session?.user) { + return NextResponse.json({ error: "인증이 필요합니다." }, { status: 401 }) + } + + const reviewId = parseInt(params.id) + if (!reviewId) { + return NextResponse.json({ error: "Invalid review ID" }, { status: 400 }) + } + + const [review] = await db + .select() + .from(rfqLastTbeDocumentReviews) + .where(eq(rfqLastTbeDocumentReviews.id, reviewId)) + .limit(1) + + if (!review) { + return NextResponse.json({ error: "Review not found" }, { status: 404 }) + } + + return NextResponse.json(review) + } catch (error) { + console.error("Failed to fetch document review:", error) + return NextResponse.json({ + error: "Failed to fetch document review" + }, { 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 3fb60347..88211f5b 100644 --- a/app/api/files/[...path]/route.ts +++ b/app/api/files/[...path]/route.ts @@ -31,6 +31,7 @@ const getMimeType = (filePath: string): string => { const isAllowedPath = (requestedPath: string): boolean => { const allowedPaths = [ 'basicContract', + 'contracts', 'basicContract/template', 'basicContract/signed', 'vendorFormReportSample', @@ -64,7 +65,12 @@ export async function GET( ) { try { // 요청된 파일 경로 구성 - const requestedPath = params.path.join('/'); + const decodedPath = params.path.map(segment => + decodeURIComponent(segment) + ); + + // 디코딩된 경로로 조합 + const requestedPath = decodedPath.join('/'); console.log(`📂 파일 요청: ${requestedPath}`); @@ -124,10 +130,14 @@ export async function GET( console.log(`✅ 파일 서빙 성공: ${fileName} (${stats.size} bytes)`); - // ✅ Content-Disposition 헤더 결정 + const encodedFileName = encodeURIComponent(fileName) + .replace(/'/g, "%27") + .replace(/"/g, "%22"); + const contentDisposition = forceDownload - ? `attachment; filename="${fileName}"` // 강제 다운로드 - : `inline; filename="${fileName}"`; // 브라우저에서 열기 + ? `attachment; filename="${encodedFileName}"; filename*=UTF-8''${encodedFileName}` + : `inline; filename="${encodedFileName}"; filename*=UTF-8''${encodedFileName}`; + // Range 요청 처리 (큰 파일의 부분 다운로드 지원) const range = request.headers.get('range'); @@ -176,7 +186,12 @@ export async function HEAD( { params }: { params: { path: string[] } } ) { try { - const requestedPath = params.path.join('/'); + const decodedPath = params.path.map(segment => + decodeURIComponent(segment) + ); + + // 디코딩된 경로로 조합 + const requestedPath = decodedPath.join('/'); // ✅ HEAD 요청에서도 다운로드 강제 여부 확인 const url = new URL(request.url); @@ -207,11 +222,16 @@ export async function HEAD( const mimeType = getMimeType(filePath); const fileName = path.basename(filePath); - // ✅ HEAD 요청에서도 Content-Disposition 헤더 적용 - const contentDisposition = forceDownload - ? `attachment; filename="${fileName}"` // 강제 다운로드 - : `inline; filename="${fileName}"`; // 브라우저에서 열기 + const encodedFileName = encodeURIComponent(fileName) + .replace(/'/g, "%27") + .replace(/"/g, "%22"); + + const contentDisposition = forceDownload + ? `attachment; filename="${encodedFileName}"; filename*=UTF-8''${encodedFileName}` + : `inline; filename="${encodedFileName}"; filename*=UTF-8''${encodedFileName}`; + + return new NextResponse(null, { headers: { 'Content-Type': mimeType, diff --git a/app/api/partners/rfq-last/[id]/response/route.ts b/app/api/partners/rfq-last/[id]/response/route.ts index db320dde..1fc9d5dd 100644 --- a/app/api/partners/rfq-last/[id]/response/route.ts +++ b/app/api/partners/rfq-last/[id]/response/route.ts @@ -156,7 +156,10 @@ export async function POST( const fileRecords = [] if (files.length > 0) { - for (const file of files) { + for (let i = 0; i < files.length; i++) { + const file = files[i] + const metadata = data.fileMetadata?.[i] // 인덱스로 메타데이터 매칭 + try { const filename = `${uuidv4()}_${file.name.replace(/[^a-zA-Z0-9.-]/g, '_')}` const filepath = path.join(uploadDir, filename) @@ -165,28 +168,25 @@ export async function POST( if (file.size > 50 * 1024 * 1024) { // 50MB 이상 await saveFileStream(file, filepath) } else { - // 작은 파일은 기존 방식 const buffer = Buffer.from(await file.arrayBuffer()) await writeFile(filepath, buffer) } fileRecords.push({ vendorResponseId: result.id, - attachmentType: (file as any).attachmentType || "기타", + attachmentType: metadata?.attachmentType || "기타", // 메타데이터에서 가져옴 fileName: filename, originalFileName: file.name, filePath: `/uploads/rfq/${rfqId}/${filename}`, fileSize: file.size, fileType: file.type, - description: (file as any).description, + description: metadata?.description || "", // 메타데이터에서 가져옴 uploadedBy: session.user.id, }) } catch (fileError) { console.error(`Failed to save file ${file.name}:`, fileError) - // 파일 저장 실패 시 계속 진행 (다른 파일들은 저장) } } - // DB에 파일 정보 저장 if (fileRecords.length > 0) { await db.insert(rfqLastVendorAttachments).values(fileRecords) diff --git a/app/api/partners/tbe/[sessionId]/documents/route.ts b/app/api/partners/tbe/[sessionId]/documents/route.ts new file mode 100644 index 00000000..0045ea43 --- /dev/null +++ b/app/api/partners/tbe/[sessionId]/documents/route.ts @@ -0,0 +1,275 @@ +// app/api/partners/tbe/[sessionId]/documents/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 { + rfqLastTbeDocumentReviews, + rfqLastTbeSessions, + rfqLastTbeHistory, + rfqLastTbeVendorDocuments +} from "@/db/schema" +import { eq, and } from "drizzle-orm" +import { writeFile, mkdir } from "fs/promises" +import { createWriteStream } from "fs" +import { pipeline } from "stream/promises" +import path from "path" +import { v4 as uuidv4 } from "uuid" + +// 1GB 파일 지원을 위한 설정 +export const config = { + api: { + bodyParser: { + sizeLimit: '1gb', + }, + responseLimit: false, + }, +} + +// 스트리밍으로 파일 저장 +async function saveFileStream(file: File, filepath: string) { + const stream = file.stream() + const writeStream = createWriteStream(filepath) + await pipeline(stream, writeStream) +} + +// POST: TBE 문서 업로드 +export async function POST(request: NextRequest, { params }: { params: { sessionId: string } }) { + try { + const session = await getServerSession(authOptions) + if (!session?.user || session.user.domain !== "partners") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const tbeSessionId = Number(params.sessionId) + const formData = await request.formData() + + // ✅ 프런트 기frfqLastTbeVendorDocuments본값 'other' 등을 안전한 enum으로 매핑 + const documentType = (formData.get("documentType") as string | undefined) + const documentName = (formData.get("documentName") as string | undefined)?.trim() || "Untitled" + const description = (formData.get("description") as string | undefined) || "" + const file = formData.get("file") as File | null + + if (!file) { + return NextResponse.json({ error: "파일이 필요합니다" }, { status: 400 }) + } + + // 세션/권한 + const tbeSession = await db.query.rfqLastTbeSessions.findFirst({ + where: eq(rfqLastTbeSessions.id, tbeSessionId), + with: { vendor: true }, + }) + if (!tbeSession) return NextResponse.json({ error: "TBE 세션을 찾을 수 없습니다" }, { status: 404 }) + + // 권한 체크: 회사 기준으로 통일 (위/아래 GET도 동일 기준을 권장) + if (tbeSession.vendor?.id !== session.user.companyId) { + return NextResponse.json({ error: "권한이 없습니다" }, { status: 403 }) + } + + // 저장 경로 + const isDev = process.env.NODE_ENV === "development" + const uploadDir = isDev + ? path.join(process.cwd(), "public", "uploads", "tbe", String(tbeSessionId), "vendor") + : path.join(process.env.NAS_PATH || "/nas", "uploads", "tbe", String(tbeSessionId), "vendor") + + await mkdir(uploadDir, { recursive: true }) + + const safeOriginal = file.name.replace(/[^a-zA-Z0-9.\-_\s]/g, "_") + const filename = `${uuidv4()}_${safeOriginal}` + const filepath = path.join(uploadDir, filename) + + try { + if (file.size > 50 * 1024 * 1024) { + await saveFileStream(file, filepath) + } else { + const buffer = Buffer.from(await file.arrayBuffer()) + await writeFile(filepath, buffer) + } + } catch (e) { + console.error("파일 저장 실패:", e) + return NextResponse.json({ error: "파일 저장에 실패했습니다" }, { status: 500 }) + } + + // 트랜잭션 + const result = await db.transaction(async (tx) => { + // 1) 벤더 업로드 문서 insert + const [vendorDoc] = await tx + .insert(rfqLastTbeVendorDocuments) + .values({ + tbeSessionId, + documentType, // enum 매핑된 값 + isResponseToReviewId: null, // 필요 시 formData에서 받아 세팅 + fileName: filename, + originalFileName: file.name, + filePath: `/uploads/tbe/${tbeSessionId}/vendor/${filename}`, + fileSize: Number(file.size), + fileType: file.type || null, + documentNo: null, + revisionNo: null, + issueDate: null, + description, + submittalRemarks: null, + reviewRequired: true, + reviewStatus: "pending", + submittedBy: session.user.id, + submittedAt: new Date(), + reviewedBy: null, + reviewedAt: null, + reviewComments: null, + }) + .returning() + + // 2) (선택) 기존 리뷰 테이블에도 “벤더가 올린 검토대상 문서”로 남기고 싶다면 유지 + // 필요 없다면 아래 블록은 제거 가능 + const [documentReview] = await tx + .insert(rfqLastTbeDocumentReviews) + .values({ + tbeSessionId, + vendorAttachmentId:vendorDoc.id, + documentSource: "vendor", + documentType: documentType, // 동일 매핑 + documentName: documentName, // UX 표시용 이름 + reviewStatus: "미검토", + reviewComments: description, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning() + + // 3) 세션 상태 전환 + if (tbeSession.status === "준비중") { + await tx + .update(rfqLastTbeSessions) + .set({ + status: "진행중", + actualStartDate: new Date(), + updatedAt: new Date(), + updatedBy: session.user.id, + }) + .where(eq(rfqLastTbeSessions.id, tbeSessionId)) + } + + // 4) 이력 + await tx.insert(rfqLastTbeHistory).values({ + tbeSessionId, + actionType: "document_review", + changeDescription: `벤더 문서 업로드: ${documentName}`, + changeDetails: { + vendorDocumentId: vendorDoc.id, + documentReviewId: documentReview.id, + documentName: documentName, + documentType: documentType, + filePath: vendorDoc.filePath, + }, + performedBy: session.user.id, + performedByType: "vendor", + performedAt: new Date(), + }) + + if (tbeSession.status === "준비중") { + await tx.insert(rfqLastTbeHistory).values({ + tbeSessionId, + actionType: "status_change", + previousStatus: "준비중", + newStatus: "진행중", + changeDescription: "벤더 문서 업로드로 인한 상태 변경", + performedBy: session.user.id, + performedByType: "vendor", + performedAt: new Date(), + }) + } + + return { + vendorDoc, + documentReview, + } + }) + + return NextResponse.json({ + success: true, + data: { + vendorDocumentId: result.vendorDoc.id, + filePath: result.vendorDoc.filePath, + originalFileName: result.vendorDoc.originalFileName, + fileSize: result.vendorDoc.fileSize, + fileType: result.vendorDoc.fileType, + }, + message: "문서가 성공적으로 업로드되었습니다", + }) + } catch (error) { + console.error("TBE 문서 업로드 오류:", error) + return NextResponse.json({ error: "문서 업로드에 실패했습니다" }, { status: 500 }) + } +} + +// GET: TBE 세션의 문서 목록 조회 +export async function GET( + request: NextRequest, + { params }: { params: { sessionId: string } } +) { + try { + const session = await getServerSession(authOptions) + if (!session?.user || session.user.domain !== "partners") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const tbeSessionId = parseInt(params.sessionId) + + // TBE 세션 확인 및 권한 체크 + const tbeSession = await db.query.rfqLastTbeSessions.findFirst({ + where: eq(rfqLastTbeSessions.id, tbeSessionId), + with: { + vendor: true, + documentReviews: { + orderBy: (reviews, { desc }) => [desc(reviews.createdAt)], + } + } + }) + + if (!tbeSession) { + return NextResponse.json({ error: "TBE 세션을 찾을 수 없습니다" }, { status: 404 }) + } + + // 벤더 권한 확인 + if (tbeSession.vendor.userId !== session.user.id) { + return NextResponse.json({ error: "권한이 없습니다" }, { status: 403 }) + } + + // PDFTron 코멘트 수 집계 (필요시) + const documentsWithDetails = await Promise.all( + tbeSession.documentReviews.map(async (doc) => { + // PDFTron 코멘트 수 조회 + const pdftronComments = await db.query.rfqLastTbePdftronComments.findFirst({ + where: eq(rfqLastTbePdftronComments.documentReviewId, doc.id), + }) + + return { + ...doc, + comments: pdftronComments?.commentSummary || { + totalCount: 0, + openCount: 0, + }, + } + }) + ) + + return NextResponse.json({ + success: true, + session: { + id: tbeSession.id, + sessionCode: tbeSession.sessionCode, + sessionTitle: tbeSession.sessionTitle, + sessionStatus: tbeSession.status, + evaluationResult: tbeSession.evaluationResult, + }, + documents: documentsWithDetails, + }) + + } catch (error) { + console.error("문서 목록 조회 오류:", error) + return NextResponse.json( + { error: "문서 목록 조회에 실패했습니다" }, + { status: 500 } + ) + } +}
\ No newline at end of file diff --git a/app/api/pdftron-comments/xfdf/count/route.ts b/app/api/pdftron-comments/xfdf/count/route.ts new file mode 100644 index 00000000..19127ea9 --- /dev/null +++ b/app/api/pdftron-comments/xfdf/count/route.ts @@ -0,0 +1,171 @@ +// app/api/pdftron-comments/xfdf/count/route.ts +import { NextRequest, NextResponse } from "next/server" +import { getServerSession } from "next-auth" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import db from "@/db/db" +import { rfqLastTbePdftronComments } from "@/db/schema" +import { inArray } from "drizzle-orm" +import { parseStringPromise } from "xml2js" + +type Counts = { totalCount: number; openCount: number } + +function fromCommentSummary(summary: any | null | undefined): Counts | null { + if (!summary) return null + // commentSummary가 다음 형태를 따른다고 가정: + // { totalCount?: number, openCount?: number } 또는 유사 구조 + const t = Number((summary as any)?.totalCount) + const o = Number((summary as any)?.openCount) + if (Number.isFinite(t)) { + return { totalCount: t, openCount: Number.isFinite(o) ? o : t } + } + return null +} + +async function fromXfdfString(xfdf: string | null | undefined): Promise<Counts | null> { + if (!xfdf) return null + try { + const xml = await parseStringPromise(xfdf, { explicitArray: true }) + // XFDF 기본 구조: xfdf.annotations[0].annotation = [...] + const ann = + xml?.xfdf?.annotations?.[0]?.annotation ?? + xml?.xfdf?.fdf?.annots?.[0]?.annot ?? + [] // 방어적 + const total = Array.isArray(ann) ? ann.length : 0 + + // “오픈/클로즈드” 판단 로직은 팀의 규칙에 맞게 조정: + // - 상태(StateModel/State) 혹은 CustomData를 쓰는 경우가 많음. + // - 기본 폴백: 전부 오픈으로 간주. + let open = total + + // 예: <status>Completed</status> 이면 클로즈드로 처리 + // (실제 저장 스키마에 맞춰 커스터마이즈하세요.) + let closed = 0 + if (Array.isArray(ann)) { + for (const a of ann) { + const status = + a?.status?.[0] || + a?.["it:status"]?.[0] || + a?.state?.[0] || + a?.custom?.[0]?.status?.[0] + if ( + typeof status === "string" && + ["Completed", "Resolved", "Accepted", "Rejected", "Closed"].includes(status) + ) { + closed += 1 + } + } + } + open = Math.max(total - closed, 0) + + return { totalCount: total, openCount: open } + } catch { + return null + } +} + + +type CommentSummary = { + total?: number + open?: number + resolved?: number + rejected?: number + deferred?: number + byAuthor?: Record<string, number> + byCategory?: Record<string, number> + bySeverity?: Record<string, number> +} + +type Counts = { totalCount: number; openCount: number } + +function countsFromSummary(s?: CommentSummary | null): Counts | null { + if (!s) return null + + // 1) open이 있으면 그걸 신뢰 + if (Number.isFinite(s.open) && Number.isFinite(s.total)) { + return { totalCount: s.total!, openCount: s.open! } + } + + // 2) open이 없으면 상태 기반으로 계산 + if (Number.isFinite(s.total)) { + const resolved = Number(s.resolved ?? 0) + const rejected = Number(s.rejected ?? 0) + const deferred = Number(s.deferred ?? 0) + const open = Math.max(s.total! - resolved - rejected - deferred, 0) + return { totalCount: s.total!, openCount: open } + } + + // 3) total이 누락된 희귀 케이스 → 분포 합으로 추정 + const sum = (...recs: (Record<string, number> | undefined)[]) => + recs.reduce((acc, r) => acc + (r ? Object.values(r).reduce((a, b) => a + (b || 0), 0) : 0), 0) + + const guessedTotal = sum(s.byAuthor, s.byCategory, s.bySeverity) + if (guessedTotal > 0) { + const open = Number(s.open ?? Math.max(guessedTotal - Number(s.resolved ?? 0) - Number(s.rejected ?? 0) - Number(s.deferred ?? 0), 0)) + return { totalCount: guessedTotal, openCount: open } + } + + return null +} + +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + if (!session?.user) { + return NextResponse.json({ error: "인증이 필요합니다." }, { status: 401 }) + } + + const idsParam = request.nextUrl.searchParams.get("ids") + if (!idsParam) { + return NextResponse.json({ error: "ids is required (comma-separated)" }, { status: 400 }) + } + + const ids = idsParam + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .map((s) => Number(s)) + .filter((n) => Number.isFinite(n)) + + if (ids.length === 0) { + return NextResponse.json({ error: "no valid ids" }, { status: 400 }) + } + + // 한 번에 조회 + const rows = await db + .select() + .from(rfqLastTbePdftronComments) + .where(inArray(rfqLastTbePdftronComments.documentReviewId, ids)) + + const result: Record< + number, + { totalCount: number; openCount: number; updatedAt: string | null } + > = {} + + // 기본값: 코멘트 없음 → 0/0 + for (const id of ids) { + result[id] = { totalCount: 0, openCount: 0, updatedAt: null } + } + + // 요약 우선 → XFDF 파싱 폴백 + await Promise.all( + rows.map(async (r: any) => { + const id = Number(r.documentReviewId) + let counts = + countsFromSummary(r.commentSummary as CommentSummary) || + (await fromXfdfString(r.xfdfString)) || // 폴백 + { totalCount: 0, openCount: 0 } + + result[id] = { + totalCount: counts.totalCount, + openCount: counts.openCount, + updatedAt: r.updatedAt ?? null, + } + }) + ) + + return NextResponse.json({ data: result }) + } catch (err) { + console.error("xfdf/count GET error:", err) + return NextResponse.json({ error: "Failed to fetch counts" }, { status: 500 }) + } +} diff --git a/app/api/pdftron-comments/xfdf/route.ts b/app/api/pdftron-comments/xfdf/route.ts new file mode 100644 index 00000000..f2cd7b81 --- /dev/null +++ b/app/api/pdftron-comments/xfdf/route.ts @@ -0,0 +1,362 @@ +// app/api/pdftron-comments/xfdf/route.ts + +import { NextRequest, NextResponse } from "next/server" +import db from "@/db/db" +import { rfqLastTbePdftronComments } from "@/db/schema" +import { eq, and, desc } from "drizzle-orm" +import { getServerSession } from "next-auth" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { parseStringPromise } from "xml2js" +import { revalidateTag } from "next/cache" + +// GET - XFDF 조회 +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + if (!session?.user) { + return NextResponse.json({ error: "인증이 필요합니다." }, { status: 401 }) + } + + const searchParams = request.nextUrl.searchParams + const documentReviewId = searchParams.get('documentReviewId') + + if (!documentReviewId) { + return NextResponse.json({ error: "documentReviewId is required" }, { status: 400 }) + } + + // 해당 문서의 코멘트 조회 + const [comment] = await db + .select() + .from(rfqLastTbePdftronComments) + .where( + eq(rfqLastTbePdftronComments.documentReviewId, parseInt(documentReviewId)) + ) + .limit(1) + + if (!comment) { + return NextResponse.json({ xfdfString: null }) + } + + // 권한 체크 + const userId = typeof session.user.id === 'string' ? parseInt(session.user.id) : session.user.id + const isAdmin = (session.user as any).roles?.includes('admin') || false + const canEdit = comment.createdBy === userId || isAdmin + + return NextResponse.json({ + xfdfString: comment.xfdfString, + annotationData: comment.annotationData, + commentSummary: comment.commentSummary, + canEdit: canEdit, + createdBy: comment.createdBy, + createdByType: comment.createdByType, + lastModifiedBy: comment.lastModifiedBy, + updatedAt: comment.updatedAt + }) + } catch (error) { + console.error("Failed to fetch XFDF:", error) + return NextResponse.json({ error: "Failed to fetch XFDF" }, { status: 500 }) + } +} + +// POST - XFDF 저장 (upsert 방식) +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 { + documentReviewId, + sessionId, + pdftronDocumentId, + xfdfString, + commentSummary, + createdByType + } = body + + // 필수 필드 검증 + if (!documentReviewId || !pdftronDocumentId || !xfdfString) { + return NextResponse.json({ + error: "Missing required fields" + }, { status: 400 }) + } + + const userId = typeof session.user.id === 'string' ? parseInt(session.user.id) : session.user.id + const isAdmin = (session.user as any).roles?.includes('admin') || false + + // XFDF 파싱하여 annotation 데이터 추출 + const annotationData = await parseXFDF(xfdfString) + + // 트랜잭션으로 처리 + const result = await db.transaction(async (tx) => { + // 기존 코멘트 확인 + const [existing] = await tx + .select() + .from(rfqLastTbePdftronComments) + .where( + and( + eq(rfqLastTbePdftronComments.documentReviewId, parseInt(documentReviewId)), + eq(rfqLastTbePdftronComments.pdftronDocumentId, pdftronDocumentId) + ) + ) + .limit(1) + + if (existing) { + // 권한 체크 - 다른 사용자의 annotation 수정 방지 + if (!isAdmin) { + const currentAnnotations = existing.annotationData?.annotations || [] + const newAnnotations = annotationData.annotations || [] + + // 다른 사용자가 만든 annotation이 수정/삭제되었는지 체크 + for (const oldAnn of currentAnnotations) { + // 다른 사용자가 만든 annotation + if (oldAnn.customData?.createdBy && oldAnn.customData.createdBy !== userId) { + const newAnn = newAnnotations.find((n: any) => n.id === oldAnn.id) + + // 삭제되었거나 수정되었으면 에러 + if (!newAnn || JSON.stringify(newAnn) !== JSON.stringify(oldAnn)) { + throw new Error("You can only modify your own annotations") + } + } + } + } + + // 기존 레코드 업데이트 + const [updated] = await tx + .update(rfqLastTbePdftronComments) + .set({ + xfdfString, + annotationData, + commentSummary, + lastModifiedBy: userId, + updatedAt: new Date() + }) + .where(eq(rfqLastTbePdftronComments.id, existing.id)) + .returning() + + return updated + } else { + // 새 레코드 삽입 + const [inserted] = await tx + .insert(rfqLastTbePdftronComments) + .values({ + documentReviewId: parseInt(documentReviewId), + pdftronDocumentId, + xfdfString, + annotationData, + commentSummary, + createdBy: userId, + createdByType: createdByType || 'buyer', + lastModifiedBy: userId, + createdAt: new Date(), + updatedAt: new Date() + }) + .returning() + + return inserted + } + }) + + revalidateTag(`tbe-session-${sessionId}`) + + + return NextResponse.json(result) + } catch (error: any) { + console.error("Failed to save XFDF:", error) + + if (error.message === "You can only modify your own annotations") { + return NextResponse.json({ + error: "You can only modify your own annotations" + }, { status: 403 }) + } + + return NextResponse.json({ error: "Failed to save XFDF" }, { status: 500 }) + } +} + +// DELETE - XFDF 삭제 +export async function DELETE(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + if (!session?.user) { + return NextResponse.json({ error: "인증이 필요합니다." }, { status: 401 }) + } + + const searchParams = request.nextUrl.searchParams + const documentReviewId = searchParams.get('documentReviewId') + const tbeSessionId = searchParams.get('sessionId') + + if (!documentReviewId) { + return NextResponse.json({ + error: "Missing required parameters" + }, { status: 400 }) + } + + const userId = typeof session.user.id === 'string' ? parseInt(session.user.id) : session.user.id + const isAdmin = (session.user as any).roles?.includes('admin') || false + + // 권한 체크 + const [existing] = await db + .select() + .from(rfqLastTbePdftronComments) + .where( + eq(rfqLastTbePdftronComments.documentReviewId, parseInt(documentReviewId)) + ) + .limit(1) + + if (!existing) { + return NextResponse.json({ error: "Comment not found" }, { status: 404 }) + } + + if (existing.createdBy !== userId && !isAdmin) { + return NextResponse.json({ error: "Unauthorized" }, { status: 403 }) + } + + // 삭제 + await db + .delete(rfqLastTbePdftronComments) + .where(eq(rfqLastTbePdftronComments.id, existing.id)) + + revalidateTag(`tbe-session-${tbeSessionId}`) + + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("Failed to delete XFDF:", error) + return NextResponse.json({ error: "Failed to delete XFDF" }, { status: 500 }) + } +} + +// XFDF 파싱 함수 - xml2js 사용 +async function parseXFDF(xfdfString: string): Promise<any> { + try { + // xml2js로 파싱 + const result = await parseStringPromise(xfdfString, { + explicitArray: false, + ignoreAttrs: false, + mergeAttrs: false, + explicitRoot: false, + tagNameProcessors: [(name) => name.toLowerCase()] + }) + + const annotations: any[] = [] + + // annots 노드 확인 + const annots = result?.annots + if (!annots) { + return { annotations: [] } + } + + // 모든 annotation 타입 처리 + const annotTypes = [ + 'highlight', 'text', 'freetext', 'ink', 'square', + 'circle', 'line', 'polygon', 'polyline', 'stamp', + 'caret', 'fileattachment', 'sound', 'strikeout', + 'underline', 'squiggly', 'redact' + ] + + for (const type of annotTypes) { + const items = annots[type] + if (!items) continue + + // 배열이 아니면 배열로 변환 + const itemArray = Array.isArray(items) ? items : [items] + + for (const item of itemArray) { + const annotation: any = { + id: item.$?.name || '', + type: type, + page: parseInt(item.$?.page || '1'), + author: item.$?.title || '', + subject: item.$?.subject || '', + createdDate: item.$?.creationdate || '', + modifiedDate: item.$?.date || '', + } + + // contents 가져오기 + if (item.contents) { + annotation.contents = typeof item.contents === 'string' + ? item.contents + : item.contents._ || '' + } + + // color 가져오기 + if (item.$?.color) { + annotation.color = item.$.color + } + + // opacity 가져오기 + if (item.$?.opacity) { + annotation.opacity = parseFloat(item.$.opacity) + } + + // custom data 가져오기 + if (item.customdata) { + annotation.customData = {} + const properties = item.customdata.property + if (properties) { + const propArray = Array.isArray(properties) ? properties : [properties] + for (const prop of propArray) { + const name = prop.$?.name + const value = prop._ || prop + if (name && value) { + // 숫자 타입 변환 + if (name === 'createdBy' || name === 'resolvedBy') { + annotation.customData[name] = parseInt(value) + } else { + annotation.customData[name] = value + } + } + } + } + } + + // replies 가져오기 + if (item.reply) { + annotation.replies = [] + const replies = Array.isArray(item.reply) ? item.reply : [item.reply] + for (const reply of replies) { + annotation.replies.push({ + author: reply.$?.title || '', + contents: typeof reply.contents === 'string' + ? reply.contents + : reply.contents?._ || '', + createdDate: reply.$?.creationdate || '' + }) + } + } + + // coords 가져오기 (rect, vertices 등) + if (item.$?.rect) { + annotation.coords = item.$.rect.split(',').map(Number) + } else if (item.$?.vertices) { + annotation.coords = item.$.vertices.split(';').join(',').split(',').map(Number) + } else if (item.$?.coords) { + annotation.coords = item.$.coords.split(',').map(Number) + } + + // appearance 정보 + if (item.appearance) { + annotation.appearance = item.appearance + } + + // popup 정보 + if (item.popup) { + annotation.popup = { + open: item.popup.$?.open === 'true', + rect: item.popup.$?.rect + } + } + + annotations.push(annotation) + } + } + + return { annotations } + } catch (error) { + console.error("Failed to parse XFDF:", error) + return { annotations: [] } + } +}
\ No newline at end of file diff --git a/app/api/tbe/sessions/[sessionId]/vendor-questions/route.ts b/app/api/tbe/sessions/[sessionId]/vendor-questions/route.ts new file mode 100644 index 00000000..8308b040 --- /dev/null +++ b/app/api/tbe/sessions/[sessionId]/vendor-questions/route.ts @@ -0,0 +1,131 @@ +// app/api/tbe/sessions/[sessionId]/vendor-questions/route.ts + +import { NextRequest, NextResponse } from "next/server" +import { getServerSession } from "next-auth" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { + addVendorQuestion, + getVendorQuestions, + answerVendorQuestion +} from "@/lib/tbe-last/vendor-tbe-service" + +interface Props { + params: { + sessionId: string + } +} + +// GET: 질문 목록 조회 +export async function GET( + request: NextRequest, + { params }: Props +) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.companyId) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ) + } + + const vendorId = typeof session.user.companyId === 'string' + ? parseInt(session.user.companyId) + : session.user.companyId + + const sessionId = parseInt(params.sessionId) + + const questions = await getVendorQuestions(sessionId, vendorId) + + return NextResponse.json(questions) + + } catch (error) { + console.error("Get questions error:", error) + return NextResponse.json( + { error: "Failed to get questions" }, + { status: 500 } + ) + } +} + +// POST: 새 질문 추가 +export async function POST( + request: NextRequest, + { params }: Props +) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.companyId) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ) + } + + const vendorId = typeof session.user.companyId === 'string' + ? parseInt(session.user.companyId) + : session.user.companyId + + const sessionId = parseInt(params.sessionId) + const body = await request.json() + + const question = await addVendorQuestion( + sessionId, + vendorId, + { + category: body.category || "general", + question: body.question, + priority: body.priority || "normal", + status: "open" + } + ) + + return NextResponse.json(question) + + } catch (error) { + console.error("Add question error:", error) + return NextResponse.json( + { error: "Failed to add question" }, + { status: 500 } + ) + } +} + +// PATCH: 질문에 답변 추가 (구매자용) +export async function PATCH( + request: NextRequest, + { params }: Props +) { + try { + const session = await getServerSession(authOptions) + if (!session?.user) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ) + } + + const sessionId = parseInt(params.sessionId) + const body = await request.json() + + const { questionId, answer } = body + + if (!questionId || !answer) { + return NextResponse.json( + { error: "Question ID and answer are required" }, + { status: 400 } + ) + } + + const result = await answerVendorQuestion(sessionId, questionId, answer) + + return NextResponse.json(result) + + } catch (error) { + console.error("Answer question error:", error) + return NextResponse.json( + { error: "Failed to answer question" }, + { status: 500 } + ) + } +}
\ No newline at end of file diff --git a/app/api/tbe/sessions/[sessionId]/vendor-remarks/route.ts b/app/api/tbe/sessions/[sessionId]/vendor-remarks/route.ts new file mode 100644 index 00000000..d2dc7797 --- /dev/null +++ b/app/api/tbe/sessions/[sessionId]/vendor-remarks/route.ts @@ -0,0 +1,57 @@ +// ========================================== +// app/api/tbe/sessions/[sessionId]/vendor-remarks/route.ts +// ========================================== + +import { NextRequest, NextResponse } from "next/server" +import { getServerSession } from "next-auth" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { updateVendorRemarks } from "@/lib/tbe-last/vendor-tbe-service" + +interface Props { + params: { + sessionId: string + } +} + +// PUT: 벤더 의견 업데이트 +export async function PUT( + request: NextRequest, + { params }: Props +) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.companyId) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ) + } + + const vendorId = typeof session.user.companyId === 'string' + ? parseInt(session.user.companyId) + : session.user.companyId + + const sessionId = parseInt(params.sessionId) + const body = await request.json() + + const { remarks } = body + + if (!remarks) { + return NextResponse.json( + { error: "Remarks are required" }, + { status: 400 } + ) + } + + const updated = await updateVendorRemarks(sessionId, vendorId, remarks) + + return NextResponse.json(updated) + + } catch (error) { + console.error("Update remarks error:", error) + return NextResponse.json( + { error: "Failed to update remarks" }, + { status: 500 } + ) + } +}
\ No newline at end of file diff --git a/app/api/upload/signed-contract/route.ts b/app/api/upload/signed-contract/route.ts index 86109eec..8547f0e4 100644 --- a/app/api/upload/signed-contract/route.ts +++ b/app/api/upload/signed-contract/route.ts @@ -1,12 +1,10 @@ // app/api/upload/signed-contract/route.ts import { NextRequest, NextResponse } from 'next/server'; -import fs from 'fs/promises'; -import path from 'path'; -import { v4 as uuidv4 } from 'uuid'; import db from "@/db/db"; import { basicContract } from '@/db/schema'; import { eq } from 'drizzle-orm'; import { revalidateTag } from 'next/cache'; +import { saveBuffer } from '@/lib/file-stroage'; export async function POST(request: NextRequest) { try { @@ -19,25 +17,37 @@ export async function POST(request: NextRequest) { return NextResponse.json({ result: false, error: '필수 파라미터가 누락되었습니다.' }, { status: 400 }); } - const originalName = `${tableRowId}_${templateName}`; - const ext = path.extname(originalName); - const uniqueName = uuidv4() + ext; + // 원본 파일명 설정 + const originalFileName = `${tableRowId}_${templateName}`; - const publicDir = path.join(process.cwd(), "public", "basicContract"); - const relativePath = `/basicContract/signed/${uniqueName}`; - const absolutePath = path.join(publicDir, uniqueName); + // 파일을 Buffer로 변환 const buffer = Buffer.from(await file.arrayBuffer()); - await fs.mkdir(publicDir, { recursive: true }); - await fs.writeFile(absolutePath, buffer); + // saveBuffer 함수를 사용하여 파일 저장 + const saveResult = await saveBuffer({ + buffer: buffer, + fileName: file.name, // 실제 업로드된 파일명 + directory: 'basicContract/signed', // 저장 디렉토리 + originalName: originalFileName, // DB에 저장할 원본명 + userId: undefined // 필요시 사용자 ID 추가 + }); + + // 저장 실패 시 에러 반환 + if (!saveResult.success) { + return NextResponse.json({ + result: false, + error: saveResult.error || '파일 저장에 실패했습니다.' + }, { status: 500 }); + } + // DB 업데이트 await db.transaction(async (tx) => { await tx .update(basicContract) .set({ status: "VENDOR_SIGNED", - fileName: originalName, - filePath: relativePath, + fileName: saveResult.originalName || originalFileName, // 원본 파일명 + filePath: saveResult.publicPath, // 웹 접근 가능한 경로 updatedAt: new Date(), completedAt: new Date() }) @@ -48,7 +58,12 @@ export async function POST(request: NextRequest) { revalidateTag("basic-contract-requests"); revalidateTag("basicContractView-vendor"); - return NextResponse.json({ result: true }); + return NextResponse.json({ + result: true, + filePath: saveResult.publicPath, + fileName: saveResult.fileName + }); + } catch (error) { console.error('서명된 계약서 저장 오류:', error); const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류"; |
