메뉴관리 추가 안되는 버그 수정

This commit is contained in:
kjs 2025-10-13 15:01:37 +09:00
parent 8046c2a2e0
commit 6e41fdf039
3 changed files with 695 additions and 490 deletions

View File

@ -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,7 +311,8 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
paramIndex++; paramIndex++;
} }
const whereClause = whereConditions.length > 0 const whereClause =
whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}` ? `WHERE ${whereConditions.join(" AND ")}`
: ""; : "";
@ -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,7 +1379,8 @@ export const getDepartmentList = async (
paramIndex++; paramIndex++;
} }
const whereClause = whereConditions.length > 0 const whereClause =
whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}` ? `WHERE ${whereConditions.join(" AND ")}`
: ""; : "";
@ -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,

View File

@ -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. 🔥 전체 플로우를 하나의 트랜잭션으로 실행
let result: ExecutionResult;
try {
result = await transaction(async (client) => {
// 트랜잭션 내에서 레벨별 실행
for (const level of levels) { for (const level of levels) {
await this.executeLevel(level, nodes, edges, context); await this.executeLevel(level, nodes, edges, context, client);
} }
// 5. 결과 생성 // 5. 결과 생성
const executionTime = Date.now() - startTime; const executionTime = Date.now() - startTime;
const result = this.generateExecutionResult( const executionResult = this.generateExecutionResult(
nodes, nodes,
context, context,
executionTime executionTime
); );
logger.info(`✅ 플로우 실행 완료:`, result.summary); // 실패한 액션 노드가 있으면 롤백
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; return result;
} catch (error) {
logger.error(`❌ 플로우 실행 실패, 모든 변경사항 롤백됨:`, error);
throw error;
}
} 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);
}
} }

View File

@ -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,13 +145,25 @@ 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;
export const useFlowEditorStore = create<FlowEditorState>((set, get) => {
// 🔥 Debounced 히스토리 저장 함수 초기화
if (!debouncedSaveToHistory) {
debouncedSaveToHistory = debounce(() => {
get().saveToHistory();
}, 500); // 500ms 지연
}
return {
// 초기 상태 // 초기 상태
nodes: [], nodes: [],
edges: [], edges: [],
history: [{ nodes: [], edges: [] }], // 초기 빈 상태를 히스토리에 저장 history: [{ nodes: [], edges: [] }], // 초기 빈 상태를 히스토리에 저장
historyIndex: 0, historyIndex: 0,
maxHistorySize: 50, maxHistorySize: 50,
isRestoringHistory: false, // 🔥 히스토리 복원 중 플래그 초기화
selectedNodes: [], selectedNodes: [],
selectedEdges: [], selectedEdges: [],
flowId: null, flowId: null,
@ -205,11 +227,20 @@ export const useFlowEditorStore = create<FlowEditorState>((set, get) => ({
엣지수: snapshot.edges.length, 엣지수: snapshot.edges.length,
}); });
// 🔥 히스토리 복원 중 플래그 설정
set({ isRestoringHistory: true });
// 노드와 엣지 복원
set({ set({
nodes: JSON.parse(JSON.stringify(snapshot.nodes)), nodes: JSON.parse(JSON.stringify(snapshot.nodes)),
edges: JSON.parse(JSON.stringify(snapshot.edges)), edges: JSON.parse(JSON.stringify(snapshot.edges)),
historyIndex: newIndex, historyIndex: newIndex,
}); });
// 🔥 다음 틱에서 플래그 해제 (ReactFlow 이벤트가 모두 처리된 후)
setTimeout(() => {
set({ isRestoringHistory: false });
}, 0);
} else { } else {
console.log("❌ Undo 불가: 히스토리가 없음"); console.log("❌ Undo 불가: 히스토리가 없음");
} }
@ -231,11 +262,20 @@ export const useFlowEditorStore = create<FlowEditorState>((set, get) => ({
엣지수: snapshot.edges.length, 엣지수: snapshot.edges.length,
}); });
// 🔥 히스토리 복원 중 플래그 설정
set({ isRestoringHistory: true });
// 노드와 엣지 복원
set({ set({
nodes: JSON.parse(JSON.stringify(snapshot.nodes)), nodes: JSON.parse(JSON.stringify(snapshot.nodes)),
edges: JSON.parse(JSON.stringify(snapshot.edges)), edges: JSON.parse(JSON.stringify(snapshot.edges)),
historyIndex: newIndex, historyIndex: newIndex,
}); });
// 🔥 다음 틱에서 플래그 해제 (ReactFlow 이벤트가 모두 처리된 후)
setTimeout(() => {
set({ isRestoringHistory: false });
}, 0);
} else { } else {
console.log("❌ Redo 불가: 되돌릴 히스토리가 없음"); console.log("❌ Redo 불가: 되돌릴 히스토리가 없음");
} }
@ -264,20 +304,34 @@ export const useFlowEditorStore = create<FlowEditorState>((set, get) => ({
}, },
onNodeDragStart: () => { onNodeDragStart: () => {
// 🔥 히스토리 복원 중이면 저장하지 않음
if (get().isRestoringHistory) {
console.log("⏭️ 히스토리 복원 중, 저장 스킵");
return;
}
// 노드 드래그 시작 시 히스토리 저장 (변경 전 상태) // 노드 드래그 시작 시 히스토리 저장 (변경 전 상태)
get().saveToHistory(); get().saveToHistory();
console.log("🎯 노드 이동 시작, 변경 전 상태 히스토리 저장"); console.log("🎯 노드 이동 시작, 변경 전 상태 히스토리 저장");
}, },
addNode: (node) => { addNode: (node) => {
get().saveToHistory(); // 히스토리에 저장 // 🔥 히스토리 복원 중이 아닐 때만 저장
if (!get().isRestoringHistory) {
get().saveToHistory();
}
set((state) => ({ set((state) => ({
nodes: [...state.nodes, node], nodes: [...state.nodes, node],
})); }));
}, },
updateNode: (id, data) => { updateNode: (id, data) => {
get().saveToHistory(); // 히스토리에 저장 // 🔥 Debounced 히스토리 저장 (500ms 지연 - 타이핑 중에는 저장 안됨)
// 🔥 히스토리 복원 중이 아닐 때만 저장
if (!get().isRestoringHistory && debouncedSaveToHistory) {
debouncedSaveToHistory();
}
set((state) => ({ set((state) => ({
nodes: state.nodes.map((node) => nodes: state.nodes.map((node) =>
node.id === id node.id === id
@ -291,7 +345,10 @@ export const useFlowEditorStore = create<FlowEditorState>((set, get) => ({
}, },
removeNode: (id) => { removeNode: (id) => {
get().saveToHistory(); // 히스토리에 저장 // 🔥 히스토리 복원 중이 아닐 때만 저장
if (!get().isRestoringHistory) {
get().saveToHistory();
}
set((state) => ({ set((state) => ({
nodes: state.nodes.filter((node) => node.id !== id), nodes: state.nodes.filter((node) => node.id !== id),
edges: state.edges.filter((edge) => edge.source !== id && edge.target !== id), edges: state.edges.filter((edge) => edge.source !== id && edge.target !== id),
@ -299,7 +356,10 @@ export const useFlowEditorStore = create<FlowEditorState>((set, get) => ({
}, },
removeNodes: (ids) => { removeNodes: (ids) => {
get().saveToHistory(); // 히스토리에 저장 // 🔥 히스토리 복원 중이 아닐 때만 저장
if (!get().isRestoringHistory) {
get().saveToHistory();
}
set((state) => ({ set((state) => ({
nodes: state.nodes.filter((node) => !ids.includes(node.id)), nodes: state.nodes.filter((node) => !ids.includes(node.id)),
edges: state.edges.filter((edge) => !ids.includes(edge.source) && !ids.includes(edge.target)), edges: state.edges.filter((edge) => !ids.includes(edge.source) && !ids.includes(edge.target)),
@ -314,8 +374,9 @@ export const useFlowEditorStore = create<FlowEditorState>((set, get) => ({
onEdgesChange: (changes) => { onEdgesChange: (changes) => {
// 엣지 삭제(remove) 타입이 있으면 히스토리 저장 // 엣지 삭제(remove) 타입이 있으면 히스토리 저장
// 🔥 히스토리 복원 중이 아닐 때만 저장
const hasRemove = changes.some((change) => change.type === "remove"); const hasRemove = changes.some((change) => change.type === "remove");
if (hasRemove) { if (hasRemove && !get().isRestoringHistory) {
get().saveToHistory(); get().saveToHistory();
console.log("🔗 엣지 삭제, 변경 전 상태 히스토리 저장"); console.log("🔗 엣지 삭제, 변경 전 상태 히스토리 저장");
} }
@ -326,7 +387,11 @@ export const useFlowEditorStore = create<FlowEditorState>((set, get) => ({
}, },
onConnect: (connection) => { onConnect: (connection) => {
get().saveToHistory(); // 히스토리에 저장 // 🔥 히스토리 복원 중이 아닐 때만 저장
if (!get().isRestoringHistory) {
get().saveToHistory();
}
// 연결 검증 // 연결 검증
const validation = validateConnection(connection, get().nodes); const validation = validateConnection(connection, get().nodes);
if (!validation.valid) { if (!validation.valid) {
@ -350,14 +415,20 @@ export const useFlowEditorStore = create<FlowEditorState>((set, get) => ({
}, },
removeEdge: (id) => { removeEdge: (id) => {
get().saveToHistory(); // 히스토리에 저장 // 🔥 히스토리 복원 중이 아닐 때만 저장
if (!get().isRestoringHistory) {
get().saveToHistory();
}
set((state) => ({ set((state) => ({
edges: state.edges.filter((edge) => edge.id !== id), edges: state.edges.filter((edge) => edge.id !== id),
})); }));
}, },
removeEdges: (ids) => { removeEdges: (ids) => {
get().saveToHistory(); // 히스토리에 저장 // 🔥 히스토리 복원 중이 아닐 때만 저장
if (!get().isRestoringHistory) {
get().saveToHistory();
}
set((state) => ({ set((state) => ({
edges: state.edges.filter((edge) => !ids.includes(edge.id)), edges: state.edges.filter((edge) => !ids.includes(edge.id)),
})); }));
@ -436,10 +507,23 @@ export const useFlowEditorStore = create<FlowEditorState>((set, get) => ({
return { success: false, message: "플로우 이름을 입력해주세요." }; return { success: false, message: "플로우 이름을 입력해주세요." };
} }
// 검증 // 🔥 검증 (순환 참조 및 기타 에러 체크)
const validation = get().validateFlow(); const validation = get().validateFlow();
// 🔥 검증 실패 시 상세 메시지와 함께 저장 차단
if (!validation.valid) { if (!validation.valid) {
return { success: false, message: `검증 실패: ${validation.errors[0]?.message || "오류가 있습니다."}` }; 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 }); set({ isSaving: true });
@ -580,7 +664,8 @@ export const useFlowEditorStore = create<FlowEditorState>((set, get) => ({
return cache.data; return cache.data;
}, },
})); }; // 🔥 return 블록 종료
}); // 🔥 create 함수 종료
// ============================================================================ // ============================================================================
// 헬퍼 함수들 // 헬퍼 함수들