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