// 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 { 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: [] } } }