diff options
Diffstat (limited to 'lib/menu-v2/service.ts')
| -rw-r--r-- | lib/menu-v2/service.ts | 605 |
1 files changed, 605 insertions, 0 deletions
diff --git a/lib/menu-v2/service.ts b/lib/menu-v2/service.ts new file mode 100644 index 00000000..39ca144a --- /dev/null +++ b/lib/menu-v2/service.ts @@ -0,0 +1,605 @@ +'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<MenuTreeAdminResult> { + 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<MenuTreeActiveResult> { + 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<MenuTreeNode | null> { + 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<number, MenuTreeNode>(); + 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<Record<MenuDomain, { added: number; removed: number }>> { + const [evcp, partners] = await Promise.all([ + syncDiscoveredMenus('evcp'), + syncDiscoveredMenus('partners') + ]); + return { evcp, partners }; +} + +/** + * Get discovered menus without syncing + */ +export async function getDiscoveredMenus(): Promise<Record<MenuDomain, DiscoveredMenu[]>> { + 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<void> { + 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<void> { + 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<void> { + 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; +} |
