diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-15 14:41:01 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-15 14:41:01 +0000 |
| commit | 4ee8b24cfadf47452807fa2af801385ed60ab47c (patch) | |
| tree | e1d1fb029f0cf5519c517494bf9a545505c35700 /app/api/pdftron-comments | |
| parent | 265859d691a01cdcaaf9154f93c38765bc34df06 (diff) | |
(대표님) 작업사항 - rfqLast, tbeLast, pdfTron, userAuth
Diffstat (limited to 'app/api/pdftron-comments')
| -rw-r--r-- | app/api/pdftron-comments/xfdf/count/route.ts | 171 | ||||
| -rw-r--r-- | app/api/pdftron-comments/xfdf/route.ts | 362 |
2 files changed, 533 insertions, 0 deletions
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 |
