'use server' import db from "@/db/db" import { gtcClauses, gtcClausesTreeView, type GtcClause, type GtcClauseTreeView, type NewGtcClause } from "@/db/schema/gtc" import { users } from "@/db/schema/users" import { and, asc, count, desc, eq, ilike, or, sql, gt, lt, inArray, like } from "drizzle-orm" import { unstable_cache } from "next/cache" import { filterColumns } from "@/lib/filter-columns" import type { GetGtcClausesSchema, CreateGtcClauseSchema, UpdateGtcClauseSchema, ReorderGtcClausesSchema, BulkUpdateGtcClausesSchema, GenerateVariableNamesSchema, } from "@/lib/gtc-contract/gtc-clauses/validations" import { decryptWithServerAction } from "@/components/drm/drmUtils" import { saveDRMFile } from "@/lib/file-stroage" interface ClauseImage { id: string url: string fileName: string size: number savedName?: string mimeType?: string width?: number height?: number hash?: string } /** * GTC 조항 목록 조회 (계층구조) */ export async function getGtcClauses(input: GetGtcClausesSchema & { documentId: number }) { return unstable_cache( async () => { try { const offset = (input.page - 1) * input.perPage // 문서 ID 필터 (필수) const documentWhere = eq(gtcClausesTreeView.documentId, input.documentId) // 고급 필터 const advancedWhere = filterColumns({ table: gtcClausesTreeView, filters: input.filters, joinOperator: input.joinOperator, }) // 전역 검색 let globalWhere if (input.search) { const s = `%${input.search}%` globalWhere = or( ilike(gtcClausesTreeView.itemNumber, s), ilike(gtcClausesTreeView.subtitle, s), ilike(gtcClausesTreeView.content, s), ilike(gtcClausesTreeView.category, s) ) } // 카테고리 필터 let categoryWhere if (input.category) { categoryWhere = ilike(gtcClausesTreeView.category, `%${input.category}%`) } // 뎁스 필터 let depthWhere if (input.depth > 0) { depthWhere = eq(gtcClausesTreeView.depth, input.depth) } // 부모 필터 let parentWhere if (input.parentId > 0) { parentWhere = eq(gtcClausesTreeView.parentId, input.parentId) } // 최종 where 조건 const finalWhere = and( documentWhere, advancedWhere, globalWhere, categoryWhere, depthWhere, parentWhere, eq(gtcClausesTreeView.isActive, true) ) // 정렬 const orderBy = input.sort.length > 0 ? input.sort.map((item) => { const column = gtcClausesTreeView[item.id as keyof typeof gtcClausesTreeView] return item.desc ? desc(column) : asc(column) }) : [asc(gtcClausesTreeView.sortOrder), asc(gtcClausesTreeView.depth)] // 데이터 조회 const { data, total } = await db.transaction(async (tx) => { const data = await tx .select() .from(gtcClausesTreeView) .where(finalWhere) .orderBy(...orderBy) .limit(input.perPage) .offset(offset) const total = await countGtcClauses(tx, finalWhere) return { data, total } }) const pageCount = Math.ceil(total / input.perPage) return { data, pageCount } } catch (err) { console.error("Error fetching GTC clauses:", err) return { data: [], pageCount: 0 } } }, [JSON.stringify(input)], { revalidate: 3600, tags: [`gtc-clauses-${input.documentId}`], } )() } /** * 계층구조 트리 형태로 GTC 조항 조회 */ export async function getGtcClausesTree(documentId: number) { return unstable_cache( async () => { try { const allClauses = await db .select() .from(gtcClausesTreeView) .where( and( eq(gtcClausesTreeView.documentId, documentId), eq(gtcClausesTreeView.isActive, true) ) ) .orderBy(asc(gtcClausesTreeView.sortOrder), asc(gtcClausesTreeView.depth)) // 계층구조로 변환 return buildClausesTree(allClauses) } catch (err) { console.error("Error fetching GTC clauses tree:", err) return [] } }, [`gtc-clauses-tree-${documentId}`], { revalidate: 3600, tags: [`gtc-clauses-${documentId}`], } )() } /** * 단일 GTC 조항 조회 */ export async function getGtcClauseById(id: number) { return unstable_cache( async () => { try { const [clause] = await db .select() .from(gtcClausesTreeView) .where(eq(gtcClausesTreeView.id, id)) .limit(1) return clause || null } catch (err) { console.error("Error fetching GTC clause:", err) return null } }, [`gtc-clause-${id}`], { revalidate: 3600, tags: [`gtc-clause-${id}`], } )() } // helper: 이미지 저장 async function saveClauseImagesFromFormData( files: File[], documentId: number, userId: number ) { const saved: ClauseImage[] = [] for (const file of files) { // DRM 복호화 + 저장 const result = await saveDRMFile(file, decryptWithServerAction, `gtc/${documentId}`, String(userId)) if (!result.success || !result.publicPath) { throw new Error(result.error || "이미지 저장 실패") } // (선택) 해상도 파악 let width: number | undefined let height: number | undefined try { // 이미지일 때만 if (file.type.startsWith("image/")) { // 서버에서 해상도를 바로 읽으려면 sharp 등 라이브러리를 쓰세요. // 여기서는 비용을 줄이기 위해 생략 (원하면 sharp로 추후 보강) } } catch { /* no-op */ } saved.push({ id: uuid(), url: result.publicPath, // <- 뷰어/Docx에서 그대로 fetch 가능한 경로 fileName: file.name, savedName: result.fileName, mimeType: file.type, size: result.fileSize ?? file.size, width, height, hash: undefined, }) } return saved } // ✅ 새 서버액션: 파일 포함 생성 export async function createGtcClauseWithUploads(formData: FormData) { try { const documentId = Number(formData.get("documentId")) const parentId = formData.get("parentId") ? Number(formData.get("parentId")) : null const itemNumber = String(formData.get("itemNumber") || "") const category = (formData.get("category") as string) || "" const subtitle = String(formData.get("subtitle") || "") const content = (formData.get("content") as string) || "" const sortOrder = Number(formData.get("sortOrder") || 0) const editReason = (formData.get("editReason") as string) || "" const createdById = Number(formData.get("createdById")) // 첨부 파일들 const files: File[] = [] for (const [key, value] of formData.entries()) { if (key.startsWith("images[") && value instanceof File && value.size > 0) { files.push(value) } } // depth 계산 let depth = 0 if (parentId) { const parent = await db.query.gtcClauses.findFirst({ where: eq(gtcClauses.id, parentId) }) if (parent) depth = (parent.depth ?? 0) + 1 } console.log(files.length, "파일 확인") // 파일 저장 const images = files.length > 0 ? await saveClauseImagesFromFormData(files, documentId, createdById) : [] const newClause: NewGtcClause = { documentId, parentId, itemNumber, category, subtitle, content: content.trim() || null, images: images.length ? images : null, sortOrder: sortOrder.toString(), depth, createdById, updatedById: createdById, editReason, } const [result] = await db.insert(gtcClauses).values(newClause).returning() await revalidateGtcClausesCaches(documentId) return { data: result, error: null } } catch (error) { console.error("Error createGtcClauseWithUploads:", error) return { data: null, error: "조항 생성 중 오류가 발생했습니다." } } } function buildClauseDir(documentId: number) { const now = new Date() const y = now.getFullYear() const m = String(now.getMonth() + 1).padStart(2, "0") return `gtc/${documentId}/${y}/${m}` } async function saveUploads(files: File[], documentId: number, userId: number) { const dir = buildClauseDir(documentId) const saved: ClauseImage[] = [] for (const file of files) { if (!file || file.size === 0) continue const r = await saveDRMFile(file, decryptWithServerAction, dir, String(userId)) if (!r.success || !r.publicPath) { throw new Error(r.error || "파일 저장 실패") } saved.push({ id: uuid(), url: r.publicPath, fileName: file.name, savedName: r.fileName, mimeType: file.type, size: r.fileSize ?? file.size, }) } return saved } export async function updateGtcClauseWithUploads(formData: FormData) { try { const id = Number(formData.get("id")) const documentId = Number(formData.get("documentId")) const itemNumber = (formData.get("itemNumber") as string) || "" const category = (formData.get("category") as string) || "" const subtitle = (formData.get("subtitle") as string) || "" const content = (formData.get("content") as string) || "" const sortOrderStr = (formData.get("sortOrder") as string) || "" const isActive = String(formData.get("isActive")).toLowerCase() !== "false" const editReason = (formData.get("editReason") as string) || "" const updatedById = Number(formData.get("updatedById")) // 기존 조항 조회 const existing = await db.query.gtcClauses.findFirst({ where: eq(gtcClauses.id, id) }) if (!existing) return { data: null, error: "조항을 찾을 수 없습니다." } // 삭제될 이미지 목록 let removedImageIds: string[] = [] try { const raw = (formData.get("removedImageIds") as string) || "[]" removedImageIds = JSON.parse(raw) } catch { removedImageIds = [] } // 신규 파일 수집 const uploadFiles: File[] = [] for (const [key, value] of formData.entries()) { if (key.startsWith("images[") && value instanceof File && value.size > 0) { uploadFiles.push(value) } } // 기존 이미지 const existingImages: ClauseImage[] = Array.isArray(existing.images) ? (existing.images as any) : (existing.images ? JSON.parse(JSON.stringify(existing.images)) : []) // 삭제 반영 const kept = existingImages.filter(img => !removedImageIds.includes(img.id)) // 신규 저장 const newImages = uploadFiles.length ? await saveUploads(uploadFiles, documentId, updatedById) : [] const nextImages = [...kept, ...newImages] const updateData: Partial = { itemNumber, category, subtitle, content: content.trim() ? content.trim() : null, sortOrder: sortOrderStr ? String(sortOrderStr) : existing.sortOrder, isActive, images: nextImages.length ? nextImages : null, updatedById, updatedAt: new Date(), editReason, } // itemNumber 변경 시 fullPath 갱신 (간단 규칙) if (itemNumber && itemNumber !== existing.itemNumber) { updateData.fullPath = itemNumber } const [result] = await db.update(gtcClauses).set(updateData).where(eq(gtcClauses.id, id)).returning() await revalidateGtcClausesCaches(existing.documentId) return { data: result, error: null } } catch (error) { console.error("Error updateGtcClauseWithUploads:", error) return { data: null, error: "조항 수정 중 오류가 발생했습니다." } } } /** * GTC 조항 생성 */ export async function createGtcClause( input: CreateGtcClauseSchema & { createdById: number } ) { try { let depth = 0 if (input.parentId) { const parent = await db.query.gtcClauses.findFirst({ where: eq(gtcClauses.id, input.parentId), }) if (parent) { depth = parent.depth + 1 } } const newClause: NewGtcClause = { documentId: input.documentId, parentId: input.parentId, itemNumber: input.itemNumber, category: input.category, subtitle: input.subtitle, content: input.content?.trim() || null, images: input.images || null, // ✅ 이미지 데이터 저장 sortOrder: input.sortOrder.toString(), depth, createdById: input.createdById, updatedById: input.createdById, editReason: input.editReason, } const [result] = await db.insert(gtcClauses).values(newClause).returning() await revalidateGtcClausesCaches(input.documentId) return { data: result, error: null } } catch (error) { console.error("Error creating GTC clause:", error) return { data: null, error: "조항 생성 중 오류가 발생했습니다." } } } /** * GTC 조항 수정 */ export async function updateGtcClause( id: number, input: UpdateGtcClauseSchema & { updatedById: number } ) { try { const updateData: Partial = { updatedById: input.updatedById, updatedAt: new Date(), images: input.images || null, // ✅ 이미지 데이터 저장 editReason: input.editReason, } // 선택적 업데이트 필드들 if (input.itemNumber !== undefined) updateData.itemNumber = input.itemNumber if (input.category !== undefined) updateData.category = input.category if (input.subtitle !== undefined) updateData.subtitle = input.subtitle if (input.content !== undefined) { // 빈 문자열은 null로 저장 updateData.content = input.content && input.content.trim() ? input.content.trim() : null } if (input.sortOrder !== undefined) updateData.sortOrder = input.sortOrder.toString() if (input.isActive !== undefined) updateData.isActive = input.isActive // fullPath 재계산 (itemNumber가 변경된 경우) if (input.itemNumber) { updateData.fullPath = input.itemNumber } const [result] = await db .update(gtcClauses) .set(updateData) .where(eq(gtcClauses.id, id)) .returning() // 캐시 무효화 const clause = await db.query.gtcClauses.findFirst({ where: eq(gtcClauses.id, id), }) if (clause) { await revalidateGtcClausesCaches(clause.documentId) } return { data: result, error: null } } catch (error) { console.error("Error updating GTC clause:", error) return { data: null, error: "조항 수정 중 오류가 발생했습니다." } } } /** * GTC 조항 삭제 (소프트 삭제) */ /** * GTC 조항 복수 삭제 (실제 삭제) */ export async function deleteGtcClauses(ids: number[], deletedById: number) { try { // 삭제 실행하고 documentId 반환받기 const deletedClauses = await db .delete(gtcClauses) .where(inArray(gtcClauses.id, ids)) .returning() if (deletedClauses.length === 0) { return { data: null, error: "삭제할 조항을 찾을 수 없습니다.", deletedCount: 0 } } // documentId는 하나일 것이므로 첫 번째 것 사용 const documentId = deletedClauses[0].documentId // 캐시 무효화 await revalidateGtcClausesCaches(documentId) return { data: deletedClauses, error: null, deletedCount: deletedClauses.length } } catch (error) { console.error("Error deleting GTC clauses:", error) return { data: null, error: error instanceof Error ? error.message : "조항 삭제 중 오류가 발생했습니다.", deletedCount: 0 } } } /** * GTC 조항 단일 삭제 (실제 삭제) */ export async function deleteGtcClause(id: number, deletedById: number) { const result = await deleteGtcClauses([id], deletedById) return { data: result.data?.[0] || null, error: result.error } } /** * 조항과 모든 하위 조항을 안전하게 삭제 * (삭제 전 하위 조항 수 확인 포함) */ export async function deleteGtcClauseWithChildren(id: number, deletedById: number) { try { // 1. 먼저 해당 조항과 하위 조항 정보 조회 const clause = await db.query.gtcClauses.findFirst({ where: eq(gtcClauses.id, id), columns: { id: true, itemNumber: true, subtitle: true, documentId: true } }) if (!clause) { return { data: null, error: "조항을 찾을 수 없습니다." } } // 2. 하위 조항들 조회 const children = await db.query.gtcClauses.findMany({ where: like(gtcClauses.fullPath, `${clause.itemNumber}.%`), columns: { id: true, itemNumber: true, subtitle: true } }) // 3. 모든 조항 삭제 (본인 + 하위 조항들) const allIds = [clause.id, ...children.map(c => c.id)] const result = await deleteGtcClauses(allIds, deletedById) return { ...result, deletedClause: clause, deletedChildren: children, totalDeleted: allIds.length } } catch (error) { console.error("Error deleting GTC clause with children:", error) return { data: null, error: "조항 삭제 중 오류가 발생했습니다." } } } /** * GTC 조항 순서 변경 */ export async function reorderGtcClauses(input: ReorderGtcClausesSchema & { updatedById: number }) { try { await db.transaction(async (tx) => { for (const clause of input.clauses) { await tx .update(gtcClauses) .set({ sortOrder: clause.sortOrder.toString(), parentId: clause.parentId, depth: clause.depth, fullPath: clause.fullPath, updatedById: input.updatedById, updatedAt: new Date(), editReason: input.editReason || "조항 순서 변경", }) .where(eq(gtcClauses.id, clause.id)) } }) // 캐시 무효화 (첫 번째 조항의 documentId 사용) if (input.clauses.length > 0) { const firstClause = await db.query.gtcClauses.findFirst({ where: eq(gtcClauses.id, input.clauses[0].id), }) if (firstClause) { await revalidateGtcClausesCaches(firstClause.documentId) } } return { error: null } } catch (error) { console.error("Error reordering GTC clauses:", error) return { error: "조항 순서 변경 중 오류가 발생했습니다." } } } /** * 벌크 업데이트 */ export async function bulkUpdateGtcClauses(input: BulkUpdateGtcClausesSchema & { updatedById: number }) { try { const updateData: Partial = { updatedById: input.updatedById, updatedAt: new Date(), editReason: input.editReason, } if (input.updates.category !== undefined) updateData.category = input.updates.category if (input.updates.isActive !== undefined) updateData.isActive = input.updates.isActive await db.transaction(async (tx) => { for (const clauseId of input.clauseIds) { await tx .update(gtcClauses) .set(updateData) .where(eq(gtcClauses.id, clauseId)) } }) // 캐시 무효화 const firstClause = await db.query.gtcClauses.findFirst({ where: eq(gtcClauses.id, input.clauseIds[0]), }) if (firstClause) { await revalidateGtcClausesCaches(firstClause.documentId) } return { error: null } } catch (error) { console.error("Error bulk updating GTC clauses:", error) return { error: "조항 일괄 수정 중 오류가 발생했습니다." } } } /** * PDFTron 변수명 자동 생성 */ export async function generateVariableNames(input: GenerateVariableNamesSchema & { updatedById: number }) { try { const clauses = await db .select() .from(gtcClauses) .where( and( eq(gtcClauses.documentId, input.documentId), eq(gtcClauses.isActive, true) ) ) await db.transaction(async (tx) => { for (const clause of clauses) { const basePrefix = input.includeVendorCode && input.vendorCode ? `${input.vendorCode}_${input.prefix}` : input.prefix const pathPrefix = clause.fullPath?.replace(/\./g, "_") || clause.itemNumber.replace(/\./g, "_") const varPrefix = `${basePrefix}_${pathPrefix}` await tx .update(gtcClauses) .set({ numberVariableName: `${varPrefix}_NUMBER`, subtitleVariableName: `${varPrefix}_SUBTITLE`, contentVariableName: `${varPrefix}_CONTENT`, updatedById: input.updatedById, updatedAt: new Date(), editReason: "PDFTron 변수명 자동 생성", }) .where(eq(gtcClauses.id, clause.id)) } }) await revalidateGtcClausesCaches(input.documentId) return { error: null } } catch (error) { console.error("Error generating variable names:", error) return { error: "변수명 생성 중 오류가 발생했습니다." } } } /** * 사용자 목록 조회 (필터용) */ export async function getUsersForFilter() { return unstable_cache( async () => { try { return await db .select({ id: users.id, name: users.name, }) .from(users) .where(eq(users.isActive, true)) .orderBy(asc(users.name)) } catch (err) { console.error("Error fetching users for filter:", err) return [] } }, ["users-for-filter"], { revalidate: 3600, tags: ["users"], } )() } // ===== 유틸리티 함수들 ===== async function countGtcClauses(tx: any, where: any) { const [{ count: total }] = await tx .select({ count: count() }) .from(gtcClausesTreeView) .where(where) return total } function buildClausesTree(clauses: GtcClauseTreeView[]): GtcClauseTreeView[] { const clauseMap = new Map() const rootClauses: (GtcClauseTreeView & { children: GtcClauseTreeView[] })[] = [] // 맵 생성 clauses.forEach(clause => { clauseMap.set(clause.id, { ...clause, children: [] }) }) // 트리 구조 생성 clauses.forEach(clause => { const clauseWithChildren = clauseMap.get(clause.id)! if (clause.parentId) { const parent = clauseMap.get(clause.parentId) if (parent) { parent.children.push(clauseWithChildren) } } else { rootClauses.push(clauseWithChildren) } }) return rootClauses } async function revalidateGtcClausesCaches(documentId: number) { const { revalidateTag } = await import("next/cache") revalidateTag(`gtc-clauses-${documentId}`) revalidateTag(`gtc-clauses-tree-${documentId}`) } /** * 조항을 위로 이동 */ export async function moveGtcClauseUp(clauseId: number, updatedById: number) { try { return await db.transaction(async (tx) => { // 현재 조항 정보 조회 const [currentClause] = await tx .select() .from(gtcClauses) .where(eq(gtcClauses.id, clauseId)) .limit(1) if (!currentClause) { return { error: "조항을 찾을 수 없습니다." } } // 같은 부모 아래에서 현재 조항보다 sortOrder가 작은 조항 중 가장 큰 것 찾기 const whereCondition = currentClause.parentId ? and( eq(gtcClauses.parentId, currentClause.parentId), sql`${gtcClauses.sortOrder} < ${currentClause.sortOrder}`, eq(gtcClauses.documentId, currentClause.documentId), eq(gtcClauses.isActive, true) ) : and( sql`${gtcClauses.parentId} IS NULL`, sql`${gtcClauses.sortOrder} < ${currentClause.sortOrder}`, eq(gtcClauses.documentId, currentClause.documentId), eq(gtcClauses.isActive, true) ) const [prevClause] = await tx .select() .from(gtcClauses) .where(whereCondition) .orderBy(desc(gtcClauses.sortOrder)) .limit(1) if (!prevClause) { return { error: "이미 첫 번째 위치입니다." } } // sortOrder 교환 const tempOrder = currentClause.sortOrder await tx .update(gtcClauses) .set({ sortOrder: prevClause.sortOrder, updatedById, updatedAt: new Date(), editReason: "조항 순서 이동 (위로)" }) .where(eq(gtcClauses.id, currentClause.id)) await tx .update(gtcClauses) .set({ sortOrder: tempOrder, updatedById, updatedAt: new Date(), editReason: "조항 순서 이동 (아래로)" }) .where(eq(gtcClauses.id, prevClause.id)) await revalidateGtcClausesCaches(currentClause.documentId) return { error: null } }) } catch (error) { console.error("Error moving clause up:", error) return { error: "조항 이동 중 오류가 발생했습니다." } } } /** * 조항을 아래로 이동 */ export async function moveGtcClauseDown(clauseId: number, updatedById: number) { try { return await db.transaction(async (tx) => { // 현재 조항 정보 조회 const [currentClause] = await tx .select() .from(gtcClauses) .where(eq(gtcClauses.id, clauseId)) .limit(1) if (!currentClause) { return { error: "조항을 찾을 수 없습니다." } } // 같은 부모 아래에서 현재 조항보다 sortOrder가 큰 조항 중 가장 작은 것 찾기 const whereCondition = currentClause.parentId ? and( eq(gtcClauses.parentId, currentClause.parentId), sql`${gtcClauses.sortOrder} > ${currentClause.sortOrder}`, eq(gtcClauses.documentId, currentClause.documentId), eq(gtcClauses.isActive, true) ) : and( sql`${gtcClauses.parentId} IS NULL`, sql`${gtcClauses.sortOrder} > ${currentClause.sortOrder}`, eq(gtcClauses.documentId, currentClause.documentId), eq(gtcClauses.isActive, true) ) const [nextClause] = await tx .select() .from(gtcClauses) .where(whereCondition) .orderBy(asc(gtcClauses.sortOrder)) .limit(1) if (!nextClause) { return { error: "이미 마지막 위치입니다." } } // sortOrder 교환 const tempOrder = currentClause.sortOrder await tx .update(gtcClauses) .set({ sortOrder: nextClause.sortOrder, updatedById, updatedAt: new Date(), editReason: "조항 순서 이동 (아래로)" }) .where(eq(gtcClauses.id, currentClause.id)) await tx .update(gtcClauses) .set({ sortOrder: tempOrder, updatedById, updatedAt: new Date(), editReason: "조항 순서 이동 (위로)" }) .where(eq(gtcClauses.id, nextClause.id)) await revalidateGtcClausesCaches(currentClause.documentId) return { error: null } }) } catch (error) { console.error("Error moving clause down:", error) return { error: "조항 이동 중 오류가 발생했습니다." } } }