diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 8ebb8802..5f79176e 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -236,11 +236,15 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { if (fieldMap[searchField as string]) { if (searchField === "tel") { - whereConditions.push(`(tel ILIKE $${paramIndex} OR cell_phone ILIKE $${paramIndex})`); + whereConditions.push( + `(tel ILIKE $${paramIndex} OR cell_phone ILIKE $${paramIndex})` + ); queryParams.push(`%${searchValue}%`); paramIndex++; } else { - whereConditions.push(`${fieldMap[searchField as string]} ILIKE $${paramIndex}`); + whereConditions.push( + `${fieldMap[searchField as string]} ILIKE $${paramIndex}` + ); queryParams.push(`%${searchValue}%`); paramIndex++; } @@ -271,7 +275,9 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { // 전화번호 검색 if (search_tel && typeof search_tel === "string" && search_tel.trim()) { - whereConditions.push(`(tel ILIKE $${paramIndex} OR cell_phone ILIKE $${paramIndex})`); + whereConditions.push( + `(tel ILIKE $${paramIndex} OR cell_phone ILIKE $${paramIndex})` + ); queryParams.push(`%${search_tel.trim()}%`); paramIndex++; hasAdvancedSearch = true; @@ -305,9 +311,10 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { paramIndex++; } - const whereClause = whereConditions.length > 0 - ? `WHERE ${whereConditions.join(" AND ")}` - : ""; + const whereClause = + whereConditions.length > 0 + ? `WHERE ${whereConditions.join(" AND ")}` + : ""; // 총 개수 조회 const countQuery = ` @@ -345,7 +352,11 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { LIMIT $${paramIndex} OFFSET $${paramIndex + 1} `; - const users = await query(usersQuery, [...queryParams, Number(countPerPage), offset]); + const users = await query(usersQuery, [ + ...queryParams, + Number(countPerPage), + offset, + ]); // 응답 데이터 가공 const processedUsers = users.map((user) => ({ @@ -365,7 +376,9 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { status: user.status || "active", companyCode: user.company_code || null, locale: user.locale || null, - regDate: user.regdate ? new Date(user.regdate).toISOString().split("T")[0] : null, + regDate: user.regdate + ? new Date(user.regdate).toISOString().split("T")[0] + : null, })); const response = { @@ -498,10 +511,10 @@ export const setUserLocale = async ( } // Raw Query로 사용자 로케일 저장 - await query( - "UPDATE user_info SET locale = $1 WHERE user_id = $2", - [locale, req.user.userId] - ); + await query("UPDATE user_info SET locale = $1 WHERE user_id = $2", [ + locale, + req.user.userId, + ]); logger.info("사용자 로케일을 데이터베이스에 저장 완료", { locale, @@ -680,9 +693,13 @@ export async function getLangKeyList( langKey: row.lang_key, description: row.description, isActive: row.is_active, - createdDate: row.created_date ? new Date(row.created_date).toISOString() : null, + createdDate: row.created_date + ? new Date(row.created_date).toISOString() + : null, createdBy: row.created_by, - updatedDate: row.updated_date ? new Date(row.updated_date).toISOString() : null, + updatedDate: row.updated_date + ? new Date(row.updated_date).toISOString() + : null, updatedBy: row.updated_by, })); @@ -1010,6 +1027,9 @@ export async function saveMenu( // Raw Query를 사용한 메뉴 저장 const objid = Date.now(); // 고유 ID 생성 + // 사용자의 company_code 사용 + const companyCode = req.user?.companyCode || "*"; + const [savedMenu] = await query( `INSERT INTO menu_info ( objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng, @@ -1030,7 +1050,7 @@ export async function saveMenu( new Date(), menuData.status || "active", menuData.systemName || null, - menuData.companyCode || "*", + companyCode, menuData.langKey || null, menuData.langKeyDesc || null, ] @@ -1079,6 +1099,9 @@ export async function updateMenu( user: req.user, }); + // 사용자의 company_code 사용 + const companyCode = req.user?.companyCode || "*"; + // Raw Query를 사용한 메뉴 수정 const [updatedMenu] = await query( `UPDATE menu_info SET @@ -1106,7 +1129,7 @@ export async function updateMenu( menuData.menuDesc || null, menuData.status || "active", menuData.systemName || null, - menuData.companyCode || "*", + companyCode, menuData.langKey || null, menuData.langKeyDesc || null, Number(menuId), @@ -1356,9 +1379,10 @@ export const getDepartmentList = async ( paramIndex++; } - const whereClause = whereConditions.length > 0 - ? `WHERE ${whereConditions.join(" AND ")}` - : ""; + const whereClause = + whereConditions.length > 0 + ? `WHERE ${whereConditions.join(" AND ")}` + : ""; const departments = await query( `SELECT @@ -1970,7 +1994,9 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => { ); // 기존 사용자인지 새 사용자인지 확인 (regdate로 판단) - const isUpdate = savedUser.regdate && new Date(savedUser.regdate).getTime() < Date.now() - 1000; + const isUpdate = + savedUser.regdate && + new Date(savedUser.regdate).getTime() < Date.now() - 1000; logger.info(isUpdate ? "사용자 정보 수정 완료" : "새 사용자 등록 완료", { userId: userData.userId, diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index cb5767bf..124d2b41 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -168,22 +168,54 @@ export class NodeFlowExecutionService { const levels = this.topologicalSort(nodes, edges); logger.info(`📋 실행 순서 (레벨별):`, levels); - // 4. 레벨별 실행 - for (const level of levels) { - await this.executeLevel(level, nodes, edges, context); + // 4. 🔥 전체 플로우를 하나의 트랜잭션으로 실행 + let result: ExecutionResult; + + try { + result = await transaction(async (client) => { + // 트랜잭션 내에서 레벨별 실행 + for (const level of levels) { + await this.executeLevel(level, nodes, edges, context, client); + } + + // 5. 결과 생성 + const executionTime = Date.now() - startTime; + const executionResult = this.generateExecutionResult( + nodes, + context, + executionTime + ); + + // 실패한 액션 노드가 있으면 롤백 + const failedActionNodes = Array.from( + context.nodeResults.values() + ).filter( + (result) => + result.status === "failed" && + nodes.find( + (n: FlowNode) => + n.id === result.nodeId && this.isActionNode(n.type) + ) + ); + + if (failedActionNodes.length > 0) { + logger.warn( + `🔄 액션 노드 실패 감지 (${failedActionNodes.length}개), 트랜잭션 롤백` + ); + throw new Error( + `액션 노드 실패: ${failedActionNodes.map((n) => n.nodeId).join(", ")}` + ); + } + + return executionResult; + }); + + logger.info(`✅ 플로우 실행 완료:`, result.summary); + return result; + } catch (error) { + logger.error(`❌ 플로우 실행 실패, 모든 변경사항 롤백됨:`, error); + throw error; } - - // 5. 결과 생성 - const executionTime = Date.now() - startTime; - const result = this.generateExecutionResult( - nodes, - context, - executionTime - ); - - logger.info(`✅ 플로우 실행 완료:`, result.summary); - - return result; } catch (error) { logger.error(`❌ 플로우 실행 실패:`, error); throw error; @@ -271,13 +303,16 @@ export class NodeFlowExecutionService { nodeIds: string[], nodes: FlowNode[], edges: FlowEdge[], - context: ExecutionContext + context: ExecutionContext, + client?: any // 🔥 트랜잭션 클라이언트 (optional) ): Promise { logger.info(`⏳ 레벨 실행 시작: ${nodeIds.length}개 노드`); // Promise.allSettled로 병렬 실행 const results = await Promise.allSettled( - nodeIds.map((nodeId) => this.executeNode(nodeId, nodes, edges, context)) + nodeIds.map((nodeId) => + this.executeNode(nodeId, nodes, edges, context, client) + ) ); // 결과 저장 @@ -307,7 +342,8 @@ export class NodeFlowExecutionService { nodeId: string, nodes: FlowNode[], edges: FlowEdge[], - context: ExecutionContext + context: ExecutionContext, + client?: any // 🔥 트랜잭션 클라이언트 (optional) ): Promise { const startTime = Date.now(); const node = nodes.find((n) => n.id === nodeId); @@ -341,7 +377,12 @@ export class NodeFlowExecutionService { // 3. 노드 타입별 실행 try { - const result = await this.executeNodeByType(node, inputData, context); + const result = await this.executeNodeByType( + node, + inputData, + context, + client + ); logger.info(`✅ 노드 실행 성공: ${nodeId}`); @@ -405,7 +446,8 @@ export class NodeFlowExecutionService { private static async executeNodeByType( node: FlowNode, inputData: any, - context: ExecutionContext + context: ExecutionContext, + client?: any // 🔥 트랜잭션 클라이언트 (optional) ): Promise { switch (node.type) { case "tableSource": @@ -418,16 +460,16 @@ export class NodeFlowExecutionService { return this.executeDataTransform(node, inputData, context); case "insertAction": - return this.executeInsertAction(node, inputData, context); + return this.executeInsertAction(node, inputData, context, client); case "updateAction": - return this.executeUpdateAction(node, inputData, context); + return this.executeUpdateAction(node, inputData, context, client); case "deleteAction": - return this.executeDeleteAction(node, inputData, context); + return this.executeDeleteAction(node, inputData, context, client); case "upsertAction": - return this.executeUpsertAction(node, inputData, context); + return this.executeUpsertAction(node, inputData, context, client); case "condition": return this.executeCondition(node, inputData, context); @@ -610,14 +652,15 @@ export class NodeFlowExecutionService { private static async executeInsertAction( node: FlowNode, inputData: any, - context: ExecutionContext + context: ExecutionContext, + client?: any // 🔥 트랜잭션 클라이언트 (optional) ): Promise { const { targetType } = node.data; // 🔥 타겟 타입별 분기 switch (targetType) { case "internal": - return this.executeInternalInsert(node, inputData, context); + return this.executeInternalInsert(node, inputData, context, client); case "external": return this.executeExternalInsert(node, inputData, context); @@ -628,7 +671,7 @@ export class NodeFlowExecutionService { default: // 하위 호환성: targetType이 없으면 internal로 간주 logger.warn(`⚠️ targetType이 설정되지 않음, internal로 간주`); - return this.executeInternalInsert(node, inputData, context); + return this.executeInternalInsert(node, inputData, context, client); } } @@ -638,7 +681,8 @@ export class NodeFlowExecutionService { private static async executeInternalInsert( node: FlowNode, inputData: any, - context: ExecutionContext + context: ExecutionContext, + client?: any // 🔥 트랜잭션 클라이언트 (optional) ): Promise { const { targetTable, fieldMappings } = node.data; @@ -655,7 +699,8 @@ export class NodeFlowExecutionService { console.log("🔑 입력 데이터 필드명:", Object.keys(inputData[0])); } - return transaction(async (client) => { + // 🔥 트랜잭션 클라이언트가 있으면 사용, 없으면 독립 트랜잭션 + const executeInsert = async (txClient: any) => { const dataArray = Array.isArray(inputData) ? inputData : [inputData]; let insertedCount = 0; @@ -685,7 +730,7 @@ export class NodeFlowExecutionService { console.log("📝 실행할 SQL:", sql); console.log("📊 바인딩 값:", values); - await client.query(sql, values); + await txClient.query(sql, values); insertedCount++; } @@ -694,7 +739,14 @@ export class NodeFlowExecutionService { ); return { insertedCount }; - }); + }; + + // 🔥 클라이언트가 전달되었으면 사용, 아니면 독립 트랜잭션 생성 + if (client) { + return executeInsert(client); + } else { + return transaction(executeInsert); + } } /** @@ -1004,14 +1056,15 @@ export class NodeFlowExecutionService { private static async executeUpdateAction( node: FlowNode, inputData: any, - context: ExecutionContext + context: ExecutionContext, + client?: any // 🔥 트랜잭션 클라이언트 (optional) ): Promise { const { targetType } = node.data; // 🔥 타겟 타입별 분기 switch (targetType) { case "internal": - return this.executeInternalUpdate(node, inputData, context); + return this.executeInternalUpdate(node, inputData, context, client); case "external": return this.executeExternalUpdate(node, inputData, context); @@ -1022,7 +1075,7 @@ export class NodeFlowExecutionService { default: // 하위 호환성: targetType이 없으면 internal로 간주 logger.warn(`⚠️ targetType이 설정되지 않음, internal로 간주`); - return this.executeInternalUpdate(node, inputData, context); + return this.executeInternalUpdate(node, inputData, context, client); } } @@ -1032,7 +1085,8 @@ export class NodeFlowExecutionService { private static async executeInternalUpdate( node: FlowNode, inputData: any, - context: ExecutionContext + context: ExecutionContext, + client?: any // 🔥 트랜잭션 클라이언트 (optional) ): Promise { const { targetTable, fieldMappings, whereConditions } = node.data; @@ -1049,7 +1103,8 @@ export class NodeFlowExecutionService { console.log("🔑 입력 데이터 필드명:", Object.keys(inputData[0])); } - return transaction(async (client) => { + // 🔥 트랜잭션 클라이언트가 있으면 사용, 없으면 독립 트랜잭션 + const executeUpdate = async (txClient: any) => { const dataArray = Array.isArray(inputData) ? inputData : [inputData]; let updatedCount = 0; @@ -1088,7 +1143,7 @@ export class NodeFlowExecutionService { console.log("📝 실행할 SQL:", sql); console.log("📊 바인딩 값:", values); - const result = await client.query(sql, values); + const result = await txClient.query(sql, values); updatedCount += result.rowCount || 0; } @@ -1097,7 +1152,14 @@ export class NodeFlowExecutionService { ); return { updatedCount }; - }); + }; + + // 🔥 클라이언트가 전달되었으면 사용, 아니면 독립 트랜잭션 생성 + if (client) { + return executeUpdate(client); + } else { + return transaction(executeUpdate); + } } /** @@ -1326,14 +1388,15 @@ export class NodeFlowExecutionService { private static async executeDeleteAction( node: FlowNode, inputData: any, - context: ExecutionContext + context: ExecutionContext, + client?: any // 🔥 트랜잭션 클라이언트 (optional) ): Promise { const { targetType } = node.data; // 🔥 타겟 타입별 분기 switch (targetType) { case "internal": - return this.executeInternalDelete(node, inputData, context); + return this.executeInternalDelete(node, inputData, context, client); case "external": return this.executeExternalDelete(node, inputData, context); @@ -1344,7 +1407,7 @@ export class NodeFlowExecutionService { default: // 하위 호환성: targetType이 없으면 internal로 간주 logger.warn(`⚠️ targetType이 설정되지 않음, internal로 간주`); - return this.executeInternalDelete(node, inputData, context); + return this.executeInternalDelete(node, inputData, context, client); } } @@ -1354,7 +1417,8 @@ export class NodeFlowExecutionService { private static async executeInternalDelete( node: FlowNode, inputData: any, - context: ExecutionContext + context: ExecutionContext, + client?: any // 🔥 트랜잭션 클라이언트 (optional) ): Promise { const { targetTable, whereConditions } = node.data; @@ -1371,7 +1435,8 @@ export class NodeFlowExecutionService { console.log("🔑 입력 데이터 필드명:", Object.keys(inputData[0])); } - return transaction(async (client) => { + // 🔥 트랜잭션 클라이언트가 있으면 사용, 없으면 독립 트랜잭션 + const executeDelete = async (txClient: any) => { const dataArray = Array.isArray(inputData) ? inputData : [inputData]; let deletedCount = 0; @@ -1383,7 +1448,7 @@ export class NodeFlowExecutionService { console.log("📝 실행할 SQL:", sql); - const result = await client.query(sql, []); + const result = await txClient.query(sql, []); deletedCount += result.rowCount || 0; } @@ -1392,7 +1457,14 @@ export class NodeFlowExecutionService { ); return { deletedCount }; - }); + }; + + // 🔥 클라이언트가 전달되었으면 사용, 아니면 독립 트랜잭션 생성 + if (client) { + return executeDelete(client); + } else { + return transaction(executeDelete); + } } /** @@ -1575,14 +1647,15 @@ export class NodeFlowExecutionService { private static async executeUpsertAction( node: FlowNode, inputData: any, - context: ExecutionContext + context: ExecutionContext, + client?: any // 🔥 트랜잭션 클라이언트 (optional) ): Promise { const { targetType } = node.data; // 🔥 타겟 타입별 분기 switch (targetType) { case "internal": - return this.executeInternalUpsert(node, inputData, context); + return this.executeInternalUpsert(node, inputData, context, client); case "external": return this.executeExternalUpsert(node, inputData, context); @@ -1593,7 +1666,7 @@ export class NodeFlowExecutionService { default: // 하위 호환성: targetType이 없으면 internal로 간주 logger.warn(`⚠️ targetType이 설정되지 않음, internal로 간주`); - return this.executeInternalUpsert(node, inputData, context); + return this.executeInternalUpsert(node, inputData, context, client); } } @@ -1604,7 +1677,8 @@ export class NodeFlowExecutionService { private static async executeInternalUpsert( node: FlowNode, inputData: any, - context: ExecutionContext + context: ExecutionContext, + client?: any // 🔥 트랜잭션 클라이언트 (optional) ): Promise { const { targetTable, fieldMappings, conflictKeys } = node.data; @@ -1630,7 +1704,8 @@ export class NodeFlowExecutionService { } console.log("🔑 충돌 키:", conflictKeys); - return transaction(async (client) => { + // 🔥 트랜잭션 클라이언트가 있으면 사용, 없으면 독립 트랜잭션 + const executeUpsert = async (txClient: any) => { const dataArray = Array.isArray(inputData) ? inputData : [inputData]; let insertedCount = 0; let updatedCount = 0; @@ -1660,7 +1735,7 @@ export class NodeFlowExecutionService { console.log("🔍 존재 여부 확인 - 바인딩 값:", whereValues); const checkSql = `SELECT 1 FROM ${targetTable} WHERE ${whereConditions} LIMIT 1`; - const existingRow = await client.query(checkSql, whereValues); + const existingRow = await txClient.query(checkSql, whereValues); if (existingRow.rows.length > 0) { // 3-A. 존재하면 UPDATE @@ -1707,7 +1782,7 @@ export class NodeFlowExecutionService { values: updateValues, }); - await client.query(updateSql, updateValues); + await txClient.query(updateSql, updateValues); updatedCount++; } else { // 3-B. 없으면 INSERT @@ -1735,7 +1810,7 @@ export class NodeFlowExecutionService { conflictKeyValues, }); - await client.query(insertSql, values); + await txClient.query(insertSql, values); insertedCount++; } } @@ -1749,7 +1824,14 @@ export class NodeFlowExecutionService { updatedCount, totalCount: insertedCount + updatedCount, }; - }); + }; + + // 🔥 클라이언트가 전달되었으면 사용, 아니면 독립 트랜잭션 생성 + if (client) { + return executeUpsert(client); + } else { + return transaction(executeUpsert); + } } /** @@ -2401,4 +2483,16 @@ export class NodeFlowExecutionService { ); return expandedRows; } + + /** + * 🔥 액션 노드 여부 확인 + */ + private static isActionNode(nodeType: NodeType): boolean { + return [ + "insertAction", + "updateAction", + "deleteAction", + "upsertAction", + ].includes(nodeType); + } } diff --git a/frontend/lib/stores/flowEditorStore.ts b/frontend/lib/stores/flowEditorStore.ts index e5f66bd4..d069966b 100644 --- a/frontend/lib/stores/flowEditorStore.ts +++ b/frontend/lib/stores/flowEditorStore.ts @@ -7,6 +7,15 @@ import { Connection, Edge, EdgeChange, Node, NodeChange, addEdge, applyNodeChang import type { FlowNode, FlowEdge, NodeType, ValidationResult } from "@/types/node-editor"; import { createNodeFlow, updateNodeFlow } from "../api/nodeFlows"; +// 🔥 Debounce 유틸리티 +function debounce any>(func: T, wait: number): (...args: Parameters) => void { + let timeout: NodeJS.Timeout | null = null; + return function (...args: Parameters) { + if (timeout) clearTimeout(timeout); + timeout = setTimeout(() => func(...args), wait); + }; +} + // 🔥 외부 커넥션 캐시 타입 interface ExternalConnectionCache { data: any[]; @@ -28,6 +37,7 @@ interface FlowEditorState { history: HistorySnapshot[]; historyIndex: number; maxHistorySize: number; + isRestoringHistory: boolean; // 🔥 히스토리 복원 중 플래그 // 선택 상태 selectedNodes: string[]; @@ -135,452 +145,527 @@ interface FlowEditorState { getConnectedNodes: (nodeId: string) => { incoming: FlowNode[]; outgoing: FlowNode[] }; } -export const useFlowEditorStore = create((set, get) => ({ - // 초기 상태 - nodes: [], - edges: [], - history: [{ nodes: [], edges: [] }], // 초기 빈 상태를 히스토리에 저장 - historyIndex: 0, - maxHistorySize: 50, - selectedNodes: [], - selectedEdges: [], - flowId: null, - flowName: "새 제어 플로우", - flowDescription: "", - isExecuting: false, - isSaving: false, - showValidationPanel: false, - showPropertiesPanel: true, - validationResult: null, - externalConnectionsCache: null, // 🔥 캐시 초기화 +// 🔥 Debounced 히스토리 저장 함수 (스토어 외부에 생성) +let debouncedSaveToHistory: (() => void) | null = null; - // ======================================================================== - // 🔥 히스토리 관리 (Undo/Redo) - // ======================================================================== - - saveToHistory: () => { - const { nodes, edges, history, historyIndex, maxHistorySize } = get(); - - // 현재 상태를 스냅샷으로 저장 - const snapshot: HistorySnapshot = { - nodes: JSON.parse(JSON.stringify(nodes)), - edges: JSON.parse(JSON.stringify(edges)), - }; - - // historyIndex 이후의 히스토리 제거 (새로운 변경이 발생했으므로) - const newHistory = history.slice(0, historyIndex + 1); - newHistory.push(snapshot); - - // 최대 크기 제한 - if (newHistory.length > maxHistorySize) { - newHistory.shift(); - } - - console.log("📸 히스토리 저장:", { - 노드수: nodes.length, - 엣지수: edges.length, - 히스토리크기: newHistory.length, - 현재인덱스: newHistory.length - 1, - }); - - set({ - history: newHistory, - historyIndex: newHistory.length - 1, - }); - }, - - undo: () => { - const { history, historyIndex } = get(); - - console.log("⏪ Undo 시도:", { historyIndex, historyLength: history.length }); - - if (historyIndex > 0) { - const newIndex = historyIndex - 1; - const snapshot = history[newIndex]; - - console.log("✅ Undo 실행:", { - 이전인덱스: historyIndex, - 새인덱스: newIndex, - 노드수: snapshot.nodes.length, - 엣지수: snapshot.edges.length, - }); - - set({ - nodes: JSON.parse(JSON.stringify(snapshot.nodes)), - edges: JSON.parse(JSON.stringify(snapshot.edges)), - historyIndex: newIndex, - }); - } else { - console.log("❌ Undo 불가: 히스토리가 없음"); - } - }, - - redo: () => { - const { history, historyIndex } = get(); - - console.log("⏩ Redo 시도:", { historyIndex, historyLength: history.length }); - - if (historyIndex < history.length - 1) { - const newIndex = historyIndex + 1; - const snapshot = history[newIndex]; - - console.log("✅ Redo 실행:", { - 이전인덱스: historyIndex, - 새인덱스: newIndex, - 노드수: snapshot.nodes.length, - 엣지수: snapshot.edges.length, - }); - - set({ - nodes: JSON.parse(JSON.stringify(snapshot.nodes)), - edges: JSON.parse(JSON.stringify(snapshot.edges)), - historyIndex: newIndex, - }); - } else { - console.log("❌ Redo 불가: 되돌릴 히스토리가 없음"); - } - }, - - canUndo: () => { - const { historyIndex } = get(); - return historyIndex > 0; - }, - - canRedo: () => { - const { history, historyIndex } = get(); - return historyIndex < history.length - 1; - }, - - // ======================================================================== - // 노드 관리 - // ======================================================================== - - setNodes: (nodes) => set({ nodes }), - - onNodesChange: (changes) => { - set({ - nodes: applyNodeChanges(changes, get().nodes) as FlowNode[], - }); - }, - - onNodeDragStart: () => { - // 노드 드래그 시작 시 히스토리 저장 (변경 전 상태) - get().saveToHistory(); - console.log("🎯 노드 이동 시작, 변경 전 상태 히스토리 저장"); - }, - - addNode: (node) => { - get().saveToHistory(); // 히스토리에 저장 - set((state) => ({ - nodes: [...state.nodes, node], - })); - }, - - updateNode: (id, data) => { - get().saveToHistory(); // 히스토리에 저장 - set((state) => ({ - nodes: state.nodes.map((node) => - node.id === id - ? { - ...node, - data: { ...node.data, ...data }, - } - : node, - ), - })); - }, - - removeNode: (id) => { - get().saveToHistory(); // 히스토리에 저장 - set((state) => ({ - nodes: state.nodes.filter((node) => node.id !== id), - edges: state.edges.filter((edge) => edge.source !== id && edge.target !== id), - })); - }, - - removeNodes: (ids) => { - get().saveToHistory(); // 히스토리에 저장 - set((state) => ({ - nodes: state.nodes.filter((node) => !ids.includes(node.id)), - edges: state.edges.filter((edge) => !ids.includes(edge.source) && !ids.includes(edge.target)), - })); - }, - - // ======================================================================== - // 엣지 관리 - // ======================================================================== - - setEdges: (edges) => set({ edges }), - - onEdgesChange: (changes) => { - // 엣지 삭제(remove) 타입이 있으면 히스토리 저장 - const hasRemove = changes.some((change) => change.type === "remove"); - if (hasRemove) { +export const useFlowEditorStore = create((set, get) => { + // 🔥 Debounced 히스토리 저장 함수 초기화 + if (!debouncedSaveToHistory) { + debouncedSaveToHistory = debounce(() => { get().saveToHistory(); - console.log("🔗 엣지 삭제, 변경 전 상태 히스토리 저장"); - } + }, 500); // 500ms 지연 + } - set({ - edges: applyEdgeChanges(changes, get().edges) as FlowEdge[], - }); - }, + return { + // 초기 상태 + nodes: [], + edges: [], + history: [{ nodes: [], edges: [] }], // 초기 빈 상태를 히스토리에 저장 + historyIndex: 0, + maxHistorySize: 50, + isRestoringHistory: false, // 🔥 히스토리 복원 중 플래그 초기화 + selectedNodes: [], + selectedEdges: [], + flowId: null, + flowName: "새 제어 플로우", + flowDescription: "", + isExecuting: false, + isSaving: false, + showValidationPanel: false, + showPropertiesPanel: true, + validationResult: null, + externalConnectionsCache: null, // 🔥 캐시 초기화 - onConnect: (connection) => { - get().saveToHistory(); // 히스토리에 저장 - // 연결 검증 - const validation = validateConnection(connection, get().nodes); - if (!validation.valid) { - console.warn("연결 검증 실패:", validation.error); - return; - } + // ======================================================================== + // 🔥 히스토리 관리 (Undo/Redo) + // ======================================================================== - set((state) => ({ - edges: addEdge( - { - ...connection, - type: "smoothstep", - animated: false, - data: { - validation: { valid: true }, - }, - }, - state.edges, - ) as FlowEdge[], - })); - }, + saveToHistory: () => { + const { nodes, edges, history, historyIndex, maxHistorySize } = get(); - removeEdge: (id) => { - get().saveToHistory(); // 히스토리에 저장 - set((state) => ({ - edges: state.edges.filter((edge) => edge.id !== id), - })); - }, - - removeEdges: (ids) => { - get().saveToHistory(); // 히스토리에 저장 - set((state) => ({ - edges: state.edges.filter((edge) => !ids.includes(edge.id)), - })); - }, - - // ======================================================================== - // 선택 관리 - // ======================================================================== - - selectNode: (id, multi = false) => { - set((state) => ({ - selectedNodes: multi ? [...state.selectedNodes, id] : [id], - })); - }, - - selectNodes: (ids) => { - set({ - selectedNodes: ids, - showPropertiesPanel: ids.length > 0, // 노드가 선택되면 속성창 자동으로 열기 - }); - }, - - selectEdge: (id, multi = false) => { - set((state) => ({ - selectedEdges: multi ? [...state.selectedEdges, id] : [id], - })); - }, - - clearSelection: () => { - set({ selectedNodes: [], selectedEdges: [] }); - }, - - // ======================================================================== - // 플로우 관리 - // ======================================================================== - - loadFlow: (id, name, description, nodes, edges) => { - console.log("📂 플로우 로드:", { id, name, 노드수: nodes.length, 엣지수: edges.length }); - set({ - flowId: id, - flowName: name, - flowDescription: description, - nodes, - edges, - selectedNodes: [], - selectedEdges: [], - // 로드된 상태를 히스토리의 첫 번째 스냅샷으로 저장 - history: [{ nodes: JSON.parse(JSON.stringify(nodes)), edges: JSON.parse(JSON.stringify(edges)) }], - historyIndex: 0, - }); - }, - - clearFlow: () => { - console.log("🔄 플로우 초기화"); - set({ - flowId: null, - flowName: "새 제어 플로우", - flowDescription: "", - nodes: [], - edges: [], - selectedNodes: [], - selectedEdges: [], - validationResult: null, - history: [{ nodes: [], edges: [] }], // 초기 빈 상태를 히스토리에 저장 - historyIndex: 0, - }); - }, - - setFlowName: (name) => set({ flowName: name }), - setFlowDescription: (description) => set({ flowDescription: description }), - - saveFlow: async () => { - const { flowId, flowName, flowDescription, nodes, edges } = get(); - - if (!flowName || flowName.trim() === "") { - return { success: false, message: "플로우 이름을 입력해주세요." }; - } - - // 검증 - const validation = get().validateFlow(); - if (!validation.valid) { - return { success: false, message: `검증 실패: ${validation.errors[0]?.message || "오류가 있습니다."}` }; - } - - set({ isSaving: true }); - - try { - // 플로우 데이터 직렬화 - const flowData = { - nodes: nodes.map((node) => ({ - id: node.id, - type: node.type, - position: node.position, - data: node.data, - })), - edges: edges.map((edge) => ({ - id: edge.id, - source: edge.source, - target: edge.target, - sourceHandle: edge.sourceHandle, - targetHandle: edge.targetHandle, - })), + // 현재 상태를 스냅샷으로 저장 + const snapshot: HistorySnapshot = { + nodes: JSON.parse(JSON.stringify(nodes)), + edges: JSON.parse(JSON.stringify(edges)), }; - const result = flowId - ? await updateNodeFlow({ - flowId, - flowName, - flowDescription, - flowData: JSON.stringify(flowData), - }) - : await createNodeFlow({ - flowName, - flowDescription, - flowData: JSON.stringify(flowData), - }); + // historyIndex 이후의 히스토리 제거 (새로운 변경이 발생했으므로) + const newHistory = history.slice(0, historyIndex + 1); + newHistory.push(snapshot); - set({ flowId: result.flowId }); - return { success: true, flowId: result.flowId, message: "저장 완료!" }; - } catch (error) { - console.error("플로우 저장 오류:", error); - return { success: false, message: error instanceof Error ? error.message : "저장 중 오류 발생" }; - } finally { - set({ isSaving: false }); - } - }, + // 최대 크기 제한 + if (newHistory.length > maxHistorySize) { + newHistory.shift(); + } - exportFlow: () => { - const { flowName, flowDescription, nodes, edges } = get(); - const flowData = { - flowName, - flowDescription, - nodes, - edges, - version: "1.0", - exportedAt: new Date().toISOString(), - }; - return JSON.stringify(flowData, null, 2); - }, + console.log("📸 히스토리 저장:", { + 노드수: nodes.length, + 엣지수: edges.length, + 히스토리크기: newHistory.length, + 현재인덱스: newHistory.length - 1, + }); - // ======================================================================== - // 검증 - // ======================================================================== + set({ + history: newHistory, + historyIndex: newHistory.length - 1, + }); + }, - validateFlow: () => { - const { nodes, edges } = get(); - const result = performFlowValidation(nodes, edges); - set({ validationResult: result }); - return result; - }, + undo: () => { + const { history, historyIndex } = get(); - setValidationResult: (result) => set({ validationResult: result }), + console.log("⏪ Undo 시도:", { historyIndex, historyLength: history.length }); - // ======================================================================== - // UI 상태 - // ======================================================================== + if (historyIndex > 0) { + const newIndex = historyIndex - 1; + const snapshot = history[newIndex]; - setIsExecuting: (value) => set({ isExecuting: value }), - setIsSaving: (value) => set({ isSaving: value }), - setShowValidationPanel: (value) => set({ showValidationPanel: value }), - setShowPropertiesPanel: (value) => set({ showPropertiesPanel: value }), + console.log("✅ Undo 실행:", { + 이전인덱스: historyIndex, + 새인덱스: newIndex, + 노드수: snapshot.nodes.length, + 엣지수: snapshot.edges.length, + }); - // ======================================================================== - // 유틸리티 - // ======================================================================== + // 🔥 히스토리 복원 중 플래그 설정 + set({ isRestoringHistory: true }); - getNodeById: (id) => { - return get().nodes.find((node) => node.id === id); - }, + // 노드와 엣지 복원 + set({ + nodes: JSON.parse(JSON.stringify(snapshot.nodes)), + edges: JSON.parse(JSON.stringify(snapshot.edges)), + historyIndex: newIndex, + }); - getEdgeById: (id) => { - return get().edges.find((edge) => edge.id === id); - }, + // 🔥 다음 틱에서 플래그 해제 (ReactFlow 이벤트가 모두 처리된 후) + setTimeout(() => { + set({ isRestoringHistory: false }); + }, 0); + } else { + console.log("❌ Undo 불가: 히스토리가 없음"); + } + }, - getConnectedNodes: (nodeId) => { - const { nodes, edges } = get(); + redo: () => { + const { history, historyIndex } = get(); - const incoming = edges - .filter((edge) => edge.target === nodeId) - .map((edge) => nodes.find((node) => node.id === edge.source)) - .filter((node): node is FlowNode => node !== undefined); + console.log("⏩ Redo 시도:", { historyIndex, historyLength: history.length }); - const outgoing = edges - .filter((edge) => edge.source === nodeId) - .map((edge) => nodes.find((node) => node.id === edge.target)) - .filter((node): node is FlowNode => node !== undefined); + if (historyIndex < history.length - 1) { + const newIndex = historyIndex + 1; + const snapshot = history[newIndex]; - return { incoming, outgoing }; - }, + console.log("✅ Redo 실행:", { + 이전인덱스: historyIndex, + 새인덱스: newIndex, + 노드수: snapshot.nodes.length, + 엣지수: snapshot.edges.length, + }); - // ======================================================================== - // 🔥 외부 커넥션 캐시 관리 - // ======================================================================== + // 🔥 히스토리 복원 중 플래그 설정 + set({ isRestoringHistory: true }); - setExternalConnectionsCache: (data) => { - set({ - externalConnectionsCache: { - data, - timestamp: Date.now(), - }, - }); - }, + // 노드와 엣지 복원 + set({ + nodes: JSON.parse(JSON.stringify(snapshot.nodes)), + edges: JSON.parse(JSON.stringify(snapshot.edges)), + historyIndex: newIndex, + }); - clearExternalConnectionsCache: () => { - set({ externalConnectionsCache: null }); - }, + // 🔥 다음 틱에서 플래그 해제 (ReactFlow 이벤트가 모두 처리된 후) + setTimeout(() => { + set({ isRestoringHistory: false }); + }, 0); + } else { + console.log("❌ Redo 불가: 되돌릴 히스토리가 없음"); + } + }, - getExternalConnectionsCache: () => { - const cache = get().externalConnectionsCache; - if (!cache) return null; + canUndo: () => { + const { historyIndex } = get(); + return historyIndex > 0; + }, - // 🔥 5분 후 캐시 만료 - const CACHE_DURATION = 5 * 60 * 1000; // 5분 - const isExpired = Date.now() - cache.timestamp > CACHE_DURATION; + canRedo: () => { + const { history, historyIndex } = get(); + return historyIndex < history.length - 1; + }, - if (isExpired) { + // ======================================================================== + // 노드 관리 + // ======================================================================== + + setNodes: (nodes) => set({ nodes }), + + onNodesChange: (changes) => { + set({ + nodes: applyNodeChanges(changes, get().nodes) as FlowNode[], + }); + }, + + onNodeDragStart: () => { + // 🔥 히스토리 복원 중이면 저장하지 않음 + if (get().isRestoringHistory) { + console.log("⏭️ 히스토리 복원 중, 저장 스킵"); + return; + } + + // 노드 드래그 시작 시 히스토리 저장 (변경 전 상태) + get().saveToHistory(); + console.log("🎯 노드 이동 시작, 변경 전 상태 히스토리 저장"); + }, + + addNode: (node) => { + // 🔥 히스토리 복원 중이 아닐 때만 저장 + if (!get().isRestoringHistory) { + get().saveToHistory(); + } + set((state) => ({ + nodes: [...state.nodes, node], + })); + }, + + updateNode: (id, data) => { + // 🔥 Debounced 히스토리 저장 (500ms 지연 - 타이핑 중에는 저장 안됨) + // 🔥 히스토리 복원 중이 아닐 때만 저장 + if (!get().isRestoringHistory && debouncedSaveToHistory) { + debouncedSaveToHistory(); + } + + set((state) => ({ + nodes: state.nodes.map((node) => + node.id === id + ? { + ...node, + data: { ...node.data, ...data }, + } + : node, + ), + })); + }, + + removeNode: (id) => { + // 🔥 히스토리 복원 중이 아닐 때만 저장 + if (!get().isRestoringHistory) { + get().saveToHistory(); + } + set((state) => ({ + nodes: state.nodes.filter((node) => node.id !== id), + edges: state.edges.filter((edge) => edge.source !== id && edge.target !== id), + })); + }, + + removeNodes: (ids) => { + // 🔥 히스토리 복원 중이 아닐 때만 저장 + if (!get().isRestoringHistory) { + get().saveToHistory(); + } + set((state) => ({ + nodes: state.nodes.filter((node) => !ids.includes(node.id)), + edges: state.edges.filter((edge) => !ids.includes(edge.source) && !ids.includes(edge.target)), + })); + }, + + // ======================================================================== + // 엣지 관리 + // ======================================================================== + + setEdges: (edges) => set({ edges }), + + onEdgesChange: (changes) => { + // 엣지 삭제(remove) 타입이 있으면 히스토리 저장 + // 🔥 히스토리 복원 중이 아닐 때만 저장 + const hasRemove = changes.some((change) => change.type === "remove"); + if (hasRemove && !get().isRestoringHistory) { + get().saveToHistory(); + console.log("🔗 엣지 삭제, 변경 전 상태 히스토리 저장"); + } + + set({ + edges: applyEdgeChanges(changes, get().edges) as FlowEdge[], + }); + }, + + onConnect: (connection) => { + // 🔥 히스토리 복원 중이 아닐 때만 저장 + if (!get().isRestoringHistory) { + get().saveToHistory(); + } + + // 연결 검증 + const validation = validateConnection(connection, get().nodes); + if (!validation.valid) { + console.warn("연결 검증 실패:", validation.error); + return; + } + + set((state) => ({ + edges: addEdge( + { + ...connection, + type: "smoothstep", + animated: false, + data: { + validation: { valid: true }, + }, + }, + state.edges, + ) as FlowEdge[], + })); + }, + + removeEdge: (id) => { + // 🔥 히스토리 복원 중이 아닐 때만 저장 + if (!get().isRestoringHistory) { + get().saveToHistory(); + } + set((state) => ({ + edges: state.edges.filter((edge) => edge.id !== id), + })); + }, + + removeEdges: (ids) => { + // 🔥 히스토리 복원 중이 아닐 때만 저장 + if (!get().isRestoringHistory) { + get().saveToHistory(); + } + set((state) => ({ + edges: state.edges.filter((edge) => !ids.includes(edge.id)), + })); + }, + + // ======================================================================== + // 선택 관리 + // ======================================================================== + + selectNode: (id, multi = false) => { + set((state) => ({ + selectedNodes: multi ? [...state.selectedNodes, id] : [id], + })); + }, + + selectNodes: (ids) => { + set({ + selectedNodes: ids, + showPropertiesPanel: ids.length > 0, // 노드가 선택되면 속성창 자동으로 열기 + }); + }, + + selectEdge: (id, multi = false) => { + set((state) => ({ + selectedEdges: multi ? [...state.selectedEdges, id] : [id], + })); + }, + + clearSelection: () => { + set({ selectedNodes: [], selectedEdges: [] }); + }, + + // ======================================================================== + // 플로우 관리 + // ======================================================================== + + loadFlow: (id, name, description, nodes, edges) => { + console.log("📂 플로우 로드:", { id, name, 노드수: nodes.length, 엣지수: edges.length }); + set({ + flowId: id, + flowName: name, + flowDescription: description, + nodes, + edges, + selectedNodes: [], + selectedEdges: [], + // 로드된 상태를 히스토리의 첫 번째 스냅샷으로 저장 + history: [{ nodes: JSON.parse(JSON.stringify(nodes)), edges: JSON.parse(JSON.stringify(edges)) }], + historyIndex: 0, + }); + }, + + clearFlow: () => { + console.log("🔄 플로우 초기화"); + set({ + flowId: null, + flowName: "새 제어 플로우", + flowDescription: "", + nodes: [], + edges: [], + selectedNodes: [], + selectedEdges: [], + validationResult: null, + history: [{ nodes: [], edges: [] }], // 초기 빈 상태를 히스토리에 저장 + historyIndex: 0, + }); + }, + + setFlowName: (name) => set({ flowName: name }), + setFlowDescription: (description) => set({ flowDescription: description }), + + saveFlow: async () => { + const { flowId, flowName, flowDescription, nodes, edges } = get(); + + if (!flowName || flowName.trim() === "") { + return { success: false, message: "플로우 이름을 입력해주세요." }; + } + + // 🔥 검증 (순환 참조 및 기타 에러 체크) + const validation = get().validateFlow(); + + // 🔥 검증 실패 시 상세 메시지와 함께 저장 차단 + if (!validation.valid) { + const errorMessages = validation.errors + .filter((err) => err.severity === "error") + .map((err) => err.message) + .join(", "); + + // 🔥 검증 패널 표시하여 사용자가 오류를 확인할 수 있도록 + set({ validationResult: validation, showValidationPanel: true }); + + return { + success: false, + message: `플로우를 저장할 수 없습니다: ${errorMessages}`, + }; + } + + set({ isSaving: true }); + + try { + // 플로우 데이터 직렬화 + const flowData = { + nodes: nodes.map((node) => ({ + id: node.id, + type: node.type, + position: node.position, + data: node.data, + })), + edges: edges.map((edge) => ({ + id: edge.id, + source: edge.source, + target: edge.target, + sourceHandle: edge.sourceHandle, + targetHandle: edge.targetHandle, + })), + }; + + const result = flowId + ? await updateNodeFlow({ + flowId, + flowName, + flowDescription, + flowData: JSON.stringify(flowData), + }) + : await createNodeFlow({ + flowName, + flowDescription, + flowData: JSON.stringify(flowData), + }); + + set({ flowId: result.flowId }); + return { success: true, flowId: result.flowId, message: "저장 완료!" }; + } catch (error) { + console.error("플로우 저장 오류:", error); + return { success: false, message: error instanceof Error ? error.message : "저장 중 오류 발생" }; + } finally { + set({ isSaving: false }); + } + }, + + exportFlow: () => { + const { flowName, flowDescription, nodes, edges } = get(); + const flowData = { + flowName, + flowDescription, + nodes, + edges, + version: "1.0", + exportedAt: new Date().toISOString(), + }; + return JSON.stringify(flowData, null, 2); + }, + + // ======================================================================== + // 검증 + // ======================================================================== + + validateFlow: () => { + const { nodes, edges } = get(); + const result = performFlowValidation(nodes, edges); + set({ validationResult: result }); + return result; + }, + + setValidationResult: (result) => set({ validationResult: result }), + + // ======================================================================== + // UI 상태 + // ======================================================================== + + setIsExecuting: (value) => set({ isExecuting: value }), + setIsSaving: (value) => set({ isSaving: value }), + setShowValidationPanel: (value) => set({ showValidationPanel: value }), + setShowPropertiesPanel: (value) => set({ showPropertiesPanel: value }), + + // ======================================================================== + // 유틸리티 + // ======================================================================== + + getNodeById: (id) => { + return get().nodes.find((node) => node.id === id); + }, + + getEdgeById: (id) => { + return get().edges.find((edge) => edge.id === id); + }, + + getConnectedNodes: (nodeId) => { + const { nodes, edges } = get(); + + const incoming = edges + .filter((edge) => edge.target === nodeId) + .map((edge) => nodes.find((node) => node.id === edge.source)) + .filter((node): node is FlowNode => node !== undefined); + + const outgoing = edges + .filter((edge) => edge.source === nodeId) + .map((edge) => nodes.find((node) => node.id === edge.target)) + .filter((node): node is FlowNode => node !== undefined); + + return { incoming, outgoing }; + }, + + // ======================================================================== + // 🔥 외부 커넥션 캐시 관리 + // ======================================================================== + + setExternalConnectionsCache: (data) => { + set({ + externalConnectionsCache: { + data, + timestamp: Date.now(), + }, + }); + }, + + clearExternalConnectionsCache: () => { set({ externalConnectionsCache: null }); - return null; - } + }, - return cache.data; - }, -})); + getExternalConnectionsCache: () => { + const cache = get().externalConnectionsCache; + if (!cache) return null; + + // 🔥 5분 후 캐시 만료 + const CACHE_DURATION = 5 * 60 * 1000; // 5분 + const isExpired = Date.now() - cache.timestamp > CACHE_DURATION; + + if (isExpired) { + set({ externalConnectionsCache: null }); + return null; + } + + return cache.data; + }, + }; // 🔥 return 블록 종료 +}); // 🔥 create 함수 종료 // ============================================================================ // 헬퍼 함수들