summaryrefslogtreecommitdiff
path: root/app/api
diff options
context:
space:
mode:
Diffstat (limited to 'app/api')
-rw-r--r--app/api/contracts/prepare-template/route.ts4
-rw-r--r--app/api/document-reviews/[id]/route.ts138
-rw-r--r--app/api/files/[...path]/route.ts38
-rw-r--r--app/api/partners/rfq-last/[id]/response/route.ts12
-rw-r--r--app/api/partners/tbe/[sessionId]/documents/route.ts275
-rw-r--r--app/api/pdftron-comments/xfdf/count/route.ts171
-rw-r--r--app/api/pdftron-comments/xfdf/route.ts362
-rw-r--r--app/api/tbe/sessions/[sessionId]/vendor-questions/route.ts131
-rw-r--r--app/api/tbe/sessions/[sessionId]/vendor-remarks/route.ts57
-rw-r--r--app/api/upload/signed-contract/route.ts43
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 : "알 수 없는 오류";