summaryrefslogtreecommitdiff
path: root/lib/gtc-contract/gtc-clauses/service.ts
diff options
context:
space:
mode:
Diffstat (limited to 'lib/gtc-contract/gtc-clauses/service.ts')
-rw-r--r--lib/gtc-contract/gtc-clauses/service.ts936
1 files changed, 936 insertions, 0 deletions
diff --git a/lib/gtc-contract/gtc-clauses/service.ts b/lib/gtc-contract/gtc-clauses/service.ts
new file mode 100644
index 00000000..b6f620bc
--- /dev/null
+++ b/lib/gtc-contract/gtc-clauses/service.ts
@@ -0,0 +1,936 @@
+'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<GtcClause> = {
+ 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<GtcClause> = {
+ 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<GtcClause> = {
+ 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<number, GtcClauseTreeView & { children: GtcClauseTreeView[] }>()
+ 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: "조항 이동 중 오류가 발생했습니다." }
+ }
+} \ No newline at end of file