summaryrefslogtreecommitdiff
path: root/lib/menu-v2/service.ts
diff options
context:
space:
mode:
Diffstat (limited to 'lib/menu-v2/service.ts')
-rw-r--r--lib/menu-v2/service.ts605
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;
+}