'use server'; import fs from 'fs'; import path from 'path'; import db from "@/db/db"; import { menuTreeNodes } from "@/db/schema/menu-v2"; import { eq, and, asc, inArray, isNull } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import type { MenuDomain, MenuTreeNode, MenuTreeAdminResult, MenuTreeActiveResult, CreateMenuGroupInput, CreateGroupInput, UpdateNodeInput, ReorderNodeInput, DiscoveredMenu } from "./types"; import { DOMAIN_APP_PATHS } from "./types"; // 도메인별 전체 트리 조회 (관리 화면용) export async function getMenuTreeForAdmin(domain: MenuDomain): Promise { const nodes = await db.select() .from(menuTreeNodes) .where(eq(menuTreeNodes.domain, domain)) .orderBy(asc(menuTreeNodes.sortOrder)); // 트리에 포함될 노드들: // - menu_group (최상위 드롭다운) // - group (드롭다운 내 그룹) // - 배정된 menu (parentId !== null) // - 최상위 menu (parentId === null, isActive === true) - 단일 링크 const treeNodes = nodes.filter(n => n.nodeType === 'menu_group' || n.nodeType === 'group' || (n.nodeType === 'menu' && n.parentId !== null) || (n.nodeType === 'menu' && n.parentId === null && n.isActive) ) as MenuTreeNode[]; const tree = buildTree(treeNodes); // 미배정 메뉴 (parentId가 null이고 isActive가 false인 menu) const unassigned = nodes.filter(n => n.nodeType === 'menu' && n.parentId === null && !n.isActive ) as MenuTreeNode[]; return { tree, unassigned }; } // 도메인별 활성 트리 조회 (헤더용) export async function getActiveMenuTree(domain: MenuDomain): Promise { const nodes = await db.select() .from(menuTreeNodes) .where(and( eq(menuTreeNodes.domain, domain), eq(menuTreeNodes.isActive, true) )) .orderBy(asc(menuTreeNodes.sortOrder)); // 트리에 포함될 노드들: // - menu_group (최상위 드롭다운) // - group (드롭다운 내 그룹) // - 배정된 menu (parentId !== null) // - 최상위 menu (parentId === null) - 단일 링크 const treeNodes = nodes.filter(n => n.nodeType === 'menu_group' || n.nodeType === 'group' || n.nodeType === 'menu' ) as MenuTreeNode[]; const tree = buildTree(treeNodes); return { tree }; } // 메뉴그룹 생성 (드롭다운) export async function createMenuGroup(domain: MenuDomain, data: CreateMenuGroupInput) { const [result] = await db.insert(menuTreeNodes).values({ domain, parentId: null, nodeType: 'menu_group', titleKo: data.titleKo, titleEn: data.titleEn, sortOrder: data.sortOrder ?? 0, isActive: true, }).returning(); revalidatePath('/evcp/menu-v2'); return result; } // 그룹 생성 (메뉴그룹 하위) export async function createGroup(domain: MenuDomain, data: CreateGroupInput) { const [result] = await db.insert(menuTreeNodes).values({ domain, parentId: data.parentId, nodeType: 'group', titleKo: data.titleKo, titleEn: data.titleEn, sortOrder: data.sortOrder ?? 0, isActive: true, }).returning(); revalidatePath('/evcp/menu-v2'); return result; } // 최상위 메뉴 생성 (단일 링크 - 기존 additional 역할) export async function createTopLevelMenu(domain: MenuDomain, data: { titleKo: string; titleEn?: string; menuPath: string; sortOrder?: number; }) { const [result] = await db.insert(menuTreeNodes).values({ domain, parentId: null, nodeType: 'menu', titleKo: data.titleKo, titleEn: data.titleEn, menuPath: data.menuPath, sortOrder: data.sortOrder ?? 0, isActive: true, }).returning(); revalidatePath('/evcp/menu-v2'); return result; } // 노드 이동 (드래그앤드롭) export async function moveNode(nodeId: number, newParentId: number | null, newSortOrder: number) { await db.update(menuTreeNodes) .set({ parentId: newParentId, sortOrder: newSortOrder, updatedAt: new Date() }) .where(eq(menuTreeNodes.id, nodeId)); revalidatePath('/evcp/menu-v2'); } // 노드 수정 export async function updateNode(nodeId: number, data: UpdateNodeInput) { await db.update(menuTreeNodes) .set({ ...data, updatedAt: new Date() }) .where(eq(menuTreeNodes.id, nodeId)); revalidatePath('/evcp/menu-v2'); } // 노드 삭제 export async function deleteNode(nodeId: number) { const [node] = await db.select().from(menuTreeNodes).where(eq(menuTreeNodes.id, nodeId)).limit(1); if (!node) return; if (node.nodeType === 'menu') { // 최상위 메뉴(parentId === null)는 직접 삭제 가능 // 하위 메뉴(parentId !== null)는 미배정으로 if (node.parentId === null) { await db.delete(menuTreeNodes).where(eq(menuTreeNodes.id, nodeId)); } else { await db.update(menuTreeNodes) .set({ parentId: null, isActive: false, updatedAt: new Date() }) .where(eq(menuTreeNodes.id, nodeId)); } } else { // 메뉴그룹/그룹 삭제 시, 하위 메뉴는 미배정으로 const children = await db.select({ id: menuTreeNodes.id, nodeType: menuTreeNodes.nodeType }) .from(menuTreeNodes) .where(eq(menuTreeNodes.parentId, nodeId)); for (const child of children) { if (child.nodeType === 'menu') { // 메뉴는 미배정으로 await db.update(menuTreeNodes) .set({ parentId: null, isActive: false, updatedAt: new Date() }) .where(eq(menuTreeNodes.id, child.id)); } else if (child.nodeType === 'group') { // 그룹의 하위 메뉴도 미배정으로 await db.update(menuTreeNodes) .set({ parentId: null, isActive: false, updatedAt: new Date() }) .where(eq(menuTreeNodes.parentId, child.id)); // 그룹 삭제 await db.delete(menuTreeNodes).where(eq(menuTreeNodes.id, child.id)); } } // 본 노드 삭제 await db.delete(menuTreeNodes).where(eq(menuTreeNodes.id, nodeId)); } revalidatePath('/evcp/menu-v2'); } // 순서 일괄 변경 export async function reorderNodes(updates: ReorderNodeInput[]) { for (const { id, sortOrder } of updates) { await db.update(menuTreeNodes) .set({ sortOrder, updatedAt: new Date() }) .where(eq(menuTreeNodes.id, id)); } revalidatePath('/evcp/menu-v2'); } // 미배정 메뉴를 특정 그룹에 배정 export async function assignMenuToGroup(menuId: number, groupId: number) { await db.update(menuTreeNodes) .set({ parentId: groupId, isActive: true, updatedAt: new Date() }) .where(eq(menuTreeNodes.id, menuId)); revalidatePath('/evcp/menu-v2'); } // 미배정 메뉴를 최상위 메뉴로 활성화 export async function activateAsTopLevelMenu(menuId: number) { await db.update(menuTreeNodes) .set({ parentId: null, isActive: true, updatedAt: new Date() }) .where(eq(menuTreeNodes.id, menuId)); revalidatePath('/evcp/menu-v2'); } // 단일 노드 조회 export async function getNodeById(nodeId: number): Promise { const [node] = await db.select() .from(menuTreeNodes) .where(eq(menuTreeNodes.id, nodeId)) .limit(1); return node as MenuTreeNode | null; } // Helper: Convert flat list to tree function buildTree(nodes: MenuTreeNode[]): MenuTreeNode[] { const nodeMap = new Map(); const roots: MenuTreeNode[] = []; nodes.forEach(node => { nodeMap.set(node.id, { ...node, children: [] }); }); nodes.forEach(node => { const current = nodeMap.get(node.id)!; if (node.parentId === null) { roots.push(current); } else { const parent = nodeMap.get(node.parentId); if (parent) { if (!parent.children) parent.children = []; parent.children.push(current); } } }); const sortChildren = (nodes: MenuTreeNode[]) => { nodes.sort((a, b) => a.sortOrder - b.sortOrder); nodes.forEach(node => { if (node.children?.length) { sortChildren(node.children); } }); }; sortChildren(roots); return roots; } // ============================================ // Menu Discovery & Sync (Server Actions) // ============================================ const DYNAMIC_SEGMENT_PATTERN = /^\[.+\]$/; /** * Discover pages from app router for a specific domain */ function discoverMenusFromAppRouter(domain: MenuDomain): DiscoveredMenu[] { const { appDir, basePath } = DOMAIN_APP_PATHS[domain]; const menus: DiscoveredMenu[] = []; function scanDirectory(dir: string, currentPath: string[], routeGroup: string) { const absoluteDir = path.resolve(process.cwd(), dir); if (!fs.existsSync(absoluteDir)) return; const entries = fs.readdirSync(absoluteDir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(absoluteDir, entry.name); if (entry.isDirectory()) { if (entry.name.startsWith('(') && entry.name.endsWith(')')) { scanDirectory(fullPath, currentPath, entry.name); } else if (DYNAMIC_SEGMENT_PATTERN.test(entry.name)) { continue; } else { scanDirectory(fullPath, [...currentPath, entry.name], routeGroup); } } else if (entry.name === 'page.tsx') { const menuPath = basePath + (currentPath.length > 0 ? '/' + currentPath.join('/') : ''); menus.push({ domain, menuPath, pageFilePath: fullPath, routeGroup }); } } } scanDirectory(appDir, [], ''); return menus; } /** * Sync discovered menus for a specific domain */ export async function syncDiscoveredMenus(domain: MenuDomain): Promise<{ added: number; removed: number }> { const discovered = discoverMenusFromAppRouter(domain); const existing = await db.select({ id: menuTreeNodes.id, menuPath: menuTreeNodes.menuPath }) .from(menuTreeNodes) .where(and( eq(menuTreeNodes.domain, domain), inArray(menuTreeNodes.nodeType, ['menu', 'additional']) )); const existingPaths = new Set(existing.map(e => e.menuPath).filter(Boolean)); const newMenus = discovered.filter(d => !existingPaths.has(d.menuPath)); let added = 0; for (const menu of newMenus) { const pathSegments = menu.menuPath.split('/').filter(Boolean); const lastSegment = pathSegments[pathSegments.length - 1] || 'unknown'; await db.insert(menuTreeNodes).values({ domain, parentId: null, nodeType: 'menu', sortOrder: 0, titleKo: lastSegment, titleEn: lastSegment, menuPath: menu.menuPath, isActive: false, }); added++; } revalidatePath('/evcp/menu-v2'); return { added, removed: 0 }; } /** * Sync all domains */ export async function syncAllDomains(): Promise> { const [evcp, partners] = await Promise.all([ syncDiscoveredMenus('evcp'), syncDiscoveredMenus('partners') ]); return { evcp, partners }; } /** * Get discovered menus without syncing */ export async function getDiscoveredMenus(): Promise> { return { evcp: discoverMenusFromAppRouter('evcp'), partners: discoverMenusFromAppRouter('partners') }; } // ============================================ // Move Node Helpers // ============================================ /** * Move node up within same parent (decrease sort order) */ export async function moveNodeUp(nodeId: number): Promise { const [node] = await db.select() .from(menuTreeNodes) .where(eq(menuTreeNodes.id, nodeId)) .limit(1); if (!node) return; // Get siblings (nodes with same parent) const siblings = await db.select() .from(menuTreeNodes) .where(node.parentId === null ? and(eq(menuTreeNodes.domain, node.domain), isNull(menuTreeNodes.parentId)) : eq(menuTreeNodes.parentId, node.parentId) ) .orderBy(asc(menuTreeNodes.sortOrder)); // Find current index const currentIndex = siblings.findIndex(s => s.id === nodeId); if (currentIndex <= 0) return; // Already at top // Swap sort orders with previous node const prevNode = siblings[currentIndex - 1]; const prevSortOrder = prevNode.sortOrder; const currentSortOrder = node.sortOrder; // If sort orders are the same, assign unique values if (prevSortOrder === currentSortOrder) { await db.update(menuTreeNodes) .set({ sortOrder: currentIndex - 1, updatedAt: new Date() }) .where(eq(menuTreeNodes.id, nodeId)); await db.update(menuTreeNodes) .set({ sortOrder: currentIndex, updatedAt: new Date() }) .where(eq(menuTreeNodes.id, prevNode.id)); } else { await db.update(menuTreeNodes) .set({ sortOrder: prevSortOrder, updatedAt: new Date() }) .where(eq(menuTreeNodes.id, nodeId)); await db.update(menuTreeNodes) .set({ sortOrder: currentSortOrder, updatedAt: new Date() }) .where(eq(menuTreeNodes.id, prevNode.id)); } revalidatePath('/evcp/menu-v2'); } /** * Move node down within same parent (increase sort order) */ export async function moveNodeDown(nodeId: number): Promise { const [node] = await db.select() .from(menuTreeNodes) .where(eq(menuTreeNodes.id, nodeId)) .limit(1); if (!node) return; // Get siblings (nodes with same parent) const siblings = await db.select() .from(menuTreeNodes) .where(node.parentId === null ? and(eq(menuTreeNodes.domain, node.domain), isNull(menuTreeNodes.parentId)) : eq(menuTreeNodes.parentId, node.parentId) ) .orderBy(asc(menuTreeNodes.sortOrder)); // Find current index const currentIndex = siblings.findIndex(s => s.id === nodeId); if (currentIndex >= siblings.length - 1) return; // Already at bottom // Swap sort orders with next node const nextNode = siblings[currentIndex + 1]; const nextSortOrder = nextNode.sortOrder; const currentSortOrder = node.sortOrder; // If sort orders are the same, assign unique values if (nextSortOrder === currentSortOrder) { await db.update(menuTreeNodes) .set({ sortOrder: currentIndex + 1, updatedAt: new Date() }) .where(eq(menuTreeNodes.id, nodeId)); await db.update(menuTreeNodes) .set({ sortOrder: currentIndex, updatedAt: new Date() }) .where(eq(menuTreeNodes.id, nextNode.id)); } else { await db.update(menuTreeNodes) .set({ sortOrder: nextSortOrder, updatedAt: new Date() }) .where(eq(menuTreeNodes.id, nodeId)); await db.update(menuTreeNodes) .set({ sortOrder: currentSortOrder, updatedAt: new Date() }) .where(eq(menuTreeNodes.id, nextNode.id)); } revalidatePath('/evcp/menu-v2'); } /** * Move node to a different parent */ export async function moveNodeToParent(nodeId: number, newParentId: number | null): Promise { const [node] = await db.select() .from(menuTreeNodes) .where(eq(menuTreeNodes.id, nodeId)) .limit(1); if (!node) return; // Get max sort order in new parent const siblings = await db.select({ sortOrder: menuTreeNodes.sortOrder }) .from(menuTreeNodes) .where(newParentId === null ? and(eq(menuTreeNodes.domain, node.domain), isNull(menuTreeNodes.parentId)) : eq(menuTreeNodes.parentId, newParentId) ) .orderBy(asc(menuTreeNodes.sortOrder)); const maxSortOrder = siblings.length > 0 ? Math.max(...siblings.map(s => s.sortOrder)) + 1 : 0; await db.update(menuTreeNodes) .set({ parentId: newParentId, sortOrder: maxSortOrder, updatedAt: new Date() }) .where(eq(menuTreeNodes.id, nodeId)); revalidatePath('/evcp/menu-v2'); } /** * Get all possible parent targets for a node (for Move To dialog) * Returns items in tree order (same as Menu Structure display) * * Rules: * - menu_group: Cannot be moved (always at top level) * - group: Can only move to menu_group (not to root or other groups) * - menu: Can move to root, menu_group, or group */ export async function getAvailableParents( nodeId: number, domain: MenuDomain, nodeType: string ): Promise<{ id: number | null; title: string; depth: number }[]> { const nodes = await db.select() .from(menuTreeNodes) .where(and( eq(menuTreeNodes.domain, domain), inArray(menuTreeNodes.nodeType, ['menu_group', 'group']) )) .orderBy(asc(menuTreeNodes.sortOrder)); const result: { id: number | null; title: string; depth: number }[] = []; // For menu nodes, allow moving to root (as top-level menu) if (nodeType === 'menu') { result.push({ id: null, title: 'Top Level (Root)', depth: 0 }); } // Build tree structure const nodeMap = new Map(nodes.map(n => [n.id, n])); const menuGroups = nodes.filter(n => n.parentId === null && n.nodeType === 'menu_group'); // Helper to check if node is descendant of nodeId (prevent circular reference) const isDescendantOf = (checkNode: typeof nodes[0], ancestorId: number): boolean => { let parent = checkNode.parentId; while (parent !== null) { if (parent === ancestorId) return true; const parentNode = nodeMap.get(parent); parent = parentNode?.parentId ?? null; } return false; }; // Traverse tree in order (menu_group -> its children groups) for (const menuGroup of menuGroups) { // Skip if it's the node being moved or its descendant if (menuGroup.id === nodeId || isDescendantOf(menuGroup, nodeId)) continue; // Add menu_group result.push({ id: menuGroup.id, title: menuGroup.titleKo, depth: 1 }); // For group nodes, only menu_groups are valid targets (skip children) if (nodeType === 'group') continue; // Add children groups (sorted by sortOrder) const childGroups = nodes .filter(n => n.parentId === menuGroup.id && n.nodeType === 'group') .sort((a, b) => a.sortOrder - b.sortOrder); for (const group of childGroups) { // Skip if it's the node being moved or its descendant if (group.id === nodeId || isDescendantOf(group, nodeId)) continue; result.push({ id: group.id, title: group.titleKo, depth: 2 }); } } return result; }