From bb98e9319f474356b906deb511fdf0fdb113f35b Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 9 Dec 2025 12:13:30 +0900 Subject: [PATCH] =?UTF-8?q?=EC=99=B8=EB=B6=80=ED=98=B8=EC=B6=9C=20?= =?UTF-8?q?=EB=85=B8=EB=93=9C=EB=93=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/nodeFlowExecutionService.ts | 471 ++++++++++++++ .../dataflow/node-editor/FlowEditor.tsx | 54 +- .../node-editor/nodes/EmailActionNode.tsx | 103 ++++ .../nodes/HttpRequestActionNode.tsx | 124 ++++ .../node-editor/nodes/ScriptActionNode.tsx | 118 ++++ .../node-editor/panels/PropertiesPanel.tsx | 15 + .../properties/EmailActionProperties.tsx | 431 +++++++++++++ .../HttpRequestActionProperties.tsx | 568 +++++++++++++++++ .../properties/ScriptActionProperties.tsx | 575 ++++++++++++++++++ .../node-editor/sidebar/nodePaletteConfig.ts | 35 +- frontend/lib/stores/flowEditorStore.ts | 10 +- frontend/types/node-editor.ts | 172 +++++- 12 files changed, 2671 insertions(+), 5 deletions(-) create mode 100644 frontend/components/dataflow/node-editor/nodes/EmailActionNode.tsx create mode 100644 frontend/components/dataflow/node-editor/nodes/HttpRequestActionNode.tsx create mode 100644 frontend/components/dataflow/node-editor/nodes/ScriptActionNode.tsx create mode 100644 frontend/components/dataflow/node-editor/panels/properties/EmailActionProperties.tsx create mode 100644 frontend/components/dataflow/node-editor/panels/properties/HttpRequestActionProperties.tsx create mode 100644 frontend/components/dataflow/node-editor/panels/properties/ScriptActionProperties.tsx diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index 2abcb04c..0542b51e 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -32,6 +32,9 @@ export type NodeType = | "updateAction" | "deleteAction" | "upsertAction" + | "emailAction" // 이메일 발송 액션 + | "scriptAction" // 스크립트 실행 액션 + | "httpRequestAction" // HTTP 요청 액션 | "comment" | "log"; @@ -547,6 +550,15 @@ export class NodeFlowExecutionService { case "condition": return this.executeCondition(node, inputData, context); + case "emailAction": + return this.executeEmailAction(node, inputData, context); + + case "scriptAction": + return this.executeScriptAction(node, inputData, context); + + case "httpRequestAction": + return this.executeHttpRequestAction(node, inputData, context); + case "comment": case "log": // 로그/코멘트는 실행 없이 통과 @@ -3379,4 +3391,463 @@ export class NodeFlowExecutionService { return filteredResults; } + + // =================================================================== + // 외부 연동 액션 노드들 + // =================================================================== + + /** + * 이메일 발송 액션 노드 실행 + */ + private static async executeEmailAction( + node: FlowNode, + inputData: any, + context: ExecutionContext + ): Promise { + const { + from, + to, + cc, + bcc, + subject, + body, + bodyType, + isHtml, // 레거시 지원 + accountId: nodeAccountId, // 프론트엔드에서 선택한 계정 ID + smtpConfigId, // 레거시 지원 + attachments, + templateVariables, + } = node.data; + + logger.info(`📧 이메일 발송 노드 실행: ${node.data.displayName || node.id}`); + + // 입력 데이터를 배열로 정규화 + const dataArray = Array.isArray(inputData) ? inputData : inputData ? [inputData] : [{}]; + const results: any[] = []; + + // 동적 임포트로 순환 참조 방지 + const { mailSendSimpleService } = await import("./mailSendSimpleService"); + const { mailAccountFileService } = await import("./mailAccountFileService"); + + // 계정 ID 우선순위: nodeAccountId > smtpConfigId > 첫 번째 활성 계정 + let accountId = nodeAccountId || smtpConfigId; + if (!accountId) { + const accounts = await mailAccountFileService.getAccounts(); + const activeAccount = accounts.find((acc: any) => acc.status === "active"); + if (activeAccount) { + accountId = activeAccount.id; + logger.info(`📧 자동 선택된 메일 계정: ${activeAccount.name} (${activeAccount.email})`); + } else { + throw new Error("활성화된 메일 계정이 없습니다. 메일 계정을 먼저 설정해주세요."); + } + } + + // HTML 여부 판단 (bodyType 우선, isHtml 레거시 지원) + const useHtml = bodyType === "html" || isHtml === true; + + for (const data of dataArray) { + try { + // 템플릿 변수 치환 + const processedSubject = this.replaceTemplateVariables(subject || "", data); + const processedBody = this.replaceTemplateVariables(body || "", data); + const processedTo = this.replaceTemplateVariables(to || "", data); + const processedCc = cc ? this.replaceTemplateVariables(cc, data) : undefined; + const processedBcc = bcc ? this.replaceTemplateVariables(bcc, data) : undefined; + + // 수신자 파싱 (쉼표로 구분) + const toList = processedTo.split(",").map((email: string) => email.trim()).filter((email: string) => email); + const ccList = processedCc ? processedCc.split(",").map((email: string) => email.trim()).filter((email: string) => email) : undefined; + const bccList = processedBcc ? processedBcc.split(",").map((email: string) => email.trim()).filter((email: string) => email) : undefined; + + if (toList.length === 0) { + throw new Error("수신자 이메일 주소가 지정되지 않았습니다."); + } + + // 메일 발송 요청 + const sendResult = await mailSendSimpleService.sendMail({ + accountId, + to: toList, + cc: ccList, + bcc: bccList, + subject: processedSubject, + customHtml: useHtml ? processedBody : `
${processedBody}
`, + attachments: attachments?.map((att: any) => ({ + filename: att.type === "dataField" ? data[att.value] : att.value, + path: att.type === "dataField" ? data[att.value] : att.value, + })), + }); + + if (sendResult.success) { + logger.info(`✅ 이메일 발송 성공: ${toList.join(", ")}`); + results.push({ + success: true, + to: toList, + messageId: sendResult.messageId, + }); + } else { + logger.error(`❌ 이메일 발송 실패: ${sendResult.error}`); + results.push({ + success: false, + to: toList, + error: sendResult.error, + }); + } + } catch (error: any) { + logger.error(`❌ 이메일 발송 오류:`, error); + results.push({ + success: false, + error: error.message, + }); + } + } + + const successCount = results.filter((r) => r.success).length; + const failedCount = results.filter((r) => !r.success).length; + + logger.info(`📧 이메일 발송 완료: 성공 ${successCount}건, 실패 ${failedCount}건`); + + return { + action: "emailAction", + totalCount: results.length, + successCount, + failedCount, + results, + }; + } + + /** + * 스크립트 실행 액션 노드 실행 + */ + private static async executeScriptAction( + node: FlowNode, + inputData: any, + context: ExecutionContext + ): Promise { + const { + scriptType, + scriptPath, + arguments: scriptArgs, + workingDirectory, + environmentVariables, + timeout, + captureOutput, + } = node.data; + + logger.info(`🖥️ 스크립트 실행 노드 실행: ${node.data.displayName || node.id}`); + logger.info(` 스크립트 타입: ${scriptType}, 경로: ${scriptPath}`); + + if (!scriptPath) { + throw new Error("스크립트 경로가 지정되지 않았습니다."); + } + + // 입력 데이터를 배열로 정규화 + const dataArray = Array.isArray(inputData) ? inputData : inputData ? [inputData] : [{}]; + const results: any[] = []; + + // child_process 모듈 동적 임포트 + const { spawn } = await import("child_process"); + const path = await import("path"); + + for (const data of dataArray) { + try { + // 인자 처리 + const processedArgs: string[] = []; + if (scriptArgs && Array.isArray(scriptArgs)) { + for (const arg of scriptArgs) { + if (arg.type === "dataField") { + // 데이터 필드 참조 + const value = this.replaceTemplateVariables(arg.value, data); + processedArgs.push(value); + } else { + processedArgs.push(arg.value); + } + } + } + + // 환경 변수 처리 + const env = { + ...process.env, + ...(environmentVariables || {}), + }; + + // 스크립트 타입에 따른 명령어 결정 + let command: string; + let args: string[]; + + switch (scriptType) { + case "python": + command = "python3"; + args = [scriptPath, ...processedArgs]; + break; + case "shell": + command = "bash"; + args = [scriptPath, ...processedArgs]; + break; + case "executable": + command = scriptPath; + args = processedArgs; + break; + default: + throw new Error(`지원하지 않는 스크립트 타입: ${scriptType}`); + } + + logger.info(` 실행 명령: ${command} ${args.join(" ")}`); + + // 스크립트 실행 (Promise로 래핑) + const result = await new Promise<{ + exitCode: number | null; + stdout: string; + stderr: string; + }>((resolve, reject) => { + const childProcess = spawn(command, args, { + cwd: workingDirectory || process.cwd(), + env, + timeout: timeout || 60000, // 기본 60초 + }); + + let stdout = ""; + let stderr = ""; + + if (captureOutput !== false) { + childProcess.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + + childProcess.stderr?.on("data", (data) => { + stderr += data.toString(); + }); + } + + childProcess.on("close", (code) => { + resolve({ exitCode: code, stdout, stderr }); + }); + + childProcess.on("error", (error) => { + reject(error); + }); + }); + + if (result.exitCode === 0) { + logger.info(`✅ 스크립트 실행 성공 (종료 코드: ${result.exitCode})`); + results.push({ + success: true, + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + data, + }); + } else { + logger.warn(`⚠️ 스크립트 실행 완료 (종료 코드: ${result.exitCode})`); + results.push({ + success: false, + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + data, + }); + } + } catch (error: any) { + logger.error(`❌ 스크립트 실행 오류:`, error); + results.push({ + success: false, + error: error.message, + data, + }); + } + } + + const successCount = results.filter((r) => r.success).length; + const failedCount = results.filter((r) => !r.success).length; + + logger.info(`🖥️ 스크립트 실행 완료: 성공 ${successCount}건, 실패 ${failedCount}건`); + + return { + action: "scriptAction", + scriptType, + scriptPath, + totalCount: results.length, + successCount, + failedCount, + results, + }; + } + + /** + * HTTP 요청 액션 노드 실행 + */ + private static async executeHttpRequestAction( + node: FlowNode, + inputData: any, + context: ExecutionContext + ): Promise { + const { + url, + method, + headers, + bodyTemplate, + bodyType, + authentication, + timeout, + retryCount, + responseMapping, + } = node.data; + + logger.info(`🌐 HTTP 요청 노드 실행: ${node.data.displayName || node.id}`); + logger.info(` 메서드: ${method}, URL: ${url}`); + + if (!url) { + throw new Error("HTTP 요청 URL이 지정되지 않았습니다."); + } + + // 입력 데이터를 배열로 정규화 + const dataArray = Array.isArray(inputData) ? inputData : inputData ? [inputData] : [{}]; + const results: any[] = []; + + for (const data of dataArray) { + let currentRetry = 0; + const maxRetries = retryCount || 0; + + while (currentRetry <= maxRetries) { + try { + // URL 템플릿 변수 치환 + const processedUrl = this.replaceTemplateVariables(url, data); + + // 헤더 처리 + const processedHeaders: Record = {}; + if (headers && Array.isArray(headers)) { + for (const header of headers) { + const headerValue = + header.valueType === "dataField" + ? this.replaceTemplateVariables(header.value, data) + : header.value; + processedHeaders[header.name] = headerValue; + } + } + + // 인증 헤더 추가 + if (authentication) { + switch (authentication.type) { + case "basic": + if (authentication.username && authentication.password) { + const credentials = Buffer.from( + `${authentication.username}:${authentication.password}` + ).toString("base64"); + processedHeaders["Authorization"] = `Basic ${credentials}`; + } + break; + case "bearer": + if (authentication.token) { + processedHeaders["Authorization"] = `Bearer ${authentication.token}`; + } + break; + case "apikey": + if (authentication.apiKey) { + if (authentication.apiKeyLocation === "query") { + // 쿼리 파라미터로 추가 (URL에 추가) + const paramName = authentication.apiKeyQueryParam || "api_key"; + const separator = processedUrl.includes("?") ? "&" : "?"; + // URL은 이미 처리되었으므로 여기서는 결과에 포함 + } else { + // 헤더로 추가 + const headerName = authentication.apiKeyHeader || "X-API-Key"; + processedHeaders[headerName] = authentication.apiKey; + } + } + break; + } + } + + // Content-Type 기본값 + if (!processedHeaders["Content-Type"] && ["POST", "PUT", "PATCH"].includes(method)) { + processedHeaders["Content-Type"] = + bodyType === "json" ? "application/json" : "text/plain"; + } + + // 바디 처리 + let processedBody: string | undefined; + if (["POST", "PUT", "PATCH"].includes(method) && bodyTemplate) { + processedBody = this.replaceTemplateVariables(bodyTemplate, data); + } + + logger.info(` 요청 URL: ${processedUrl}`); + logger.info(` 요청 헤더: ${JSON.stringify(processedHeaders)}`); + if (processedBody) { + logger.info(` 요청 바디: ${processedBody.substring(0, 200)}...`); + } + + // HTTP 요청 실행 + const response = await axios({ + method: method.toLowerCase() as any, + url: processedUrl, + headers: processedHeaders, + data: processedBody, + timeout: timeout || 30000, + validateStatus: () => true, // 모든 상태 코드 허용 + }); + + logger.info(` 응답 상태: ${response.status} ${response.statusText}`); + + // 응답 데이터 처리 + let responseData = response.data; + + // 응답 매핑 적용 + if (responseMapping && responseData) { + const paths = responseMapping.split("."); + for (const path of paths) { + if (responseData && typeof responseData === "object" && path in responseData) { + responseData = responseData[path]; + } else { + logger.warn(`⚠️ 응답 매핑 경로를 찾을 수 없습니다: ${responseMapping}`); + break; + } + } + } + + const isSuccess = response.status >= 200 && response.status < 300; + + if (isSuccess) { + logger.info(`✅ HTTP 요청 성공`); + results.push({ + success: true, + statusCode: response.status, + data: responseData, + inputData: data, + }); + break; // 성공 시 재시도 루프 종료 + } else { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + } catch (error: any) { + currentRetry++; + if (currentRetry > maxRetries) { + logger.error(`❌ HTTP 요청 실패 (재시도 ${currentRetry - 1}/${maxRetries}):`, error.message); + results.push({ + success: false, + error: error.message, + inputData: data, + }); + } else { + logger.warn(`⚠️ HTTP 요청 재시도 (${currentRetry}/${maxRetries}): ${error.message}`); + // 재시도 전 잠시 대기 + await new Promise((resolve) => setTimeout(resolve, 1000 * currentRetry)); + } + } + } + } + + const successCount = results.filter((r) => r.success).length; + const failedCount = results.filter((r) => !r.success).length; + + logger.info(`🌐 HTTP 요청 완료: 성공 ${successCount}건, 실패 ${failedCount}건`); + + return { + action: "httpRequestAction", + method, + url, + totalCount: results.length, + successCount, + failedCount, + results, + }; + } } diff --git a/frontend/components/dataflow/node-editor/FlowEditor.tsx b/frontend/components/dataflow/node-editor/FlowEditor.tsx index 333a70c1..81282c0b 100644 --- a/frontend/components/dataflow/node-editor/FlowEditor.tsx +++ b/frontend/components/dataflow/node-editor/FlowEditor.tsx @@ -28,6 +28,9 @@ import { AggregateNode } from "./nodes/AggregateNode"; import { RestAPISourceNode } from "./nodes/RestAPISourceNode"; import { CommentNode } from "./nodes/CommentNode"; import { LogNode } from "./nodes/LogNode"; +import { EmailActionNode } from "./nodes/EmailActionNode"; +import { ScriptActionNode } from "./nodes/ScriptActionNode"; +import { HttpRequestActionNode } from "./nodes/HttpRequestActionNode"; import { validateFlow } from "@/lib/utils/flowValidation"; import type { FlowValidation } from "@/lib/utils/flowValidation"; @@ -41,11 +44,15 @@ const nodeTypes = { condition: ConditionNode, dataTransform: DataTransformNode, aggregate: AggregateNode, - // 액션 + // 데이터 액션 insertAction: InsertActionNode, updateAction: UpdateActionNode, deleteAction: DeleteActionNode, upsertAction: UpsertActionNode, + // 외부 연동 액션 + emailAction: EmailActionNode, + scriptAction: ScriptActionNode, + httpRequestAction: HttpRequestActionNode, // 유틸리티 comment: CommentNode, log: LogNode, @@ -246,7 +253,7 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) { defaultData.responseMapping = ""; } - // 액션 노드의 경우 targetType 기본값 설정 + // 데이터 액션 노드의 경우 targetType 기본값 설정 if (["insertAction", "updateAction", "deleteAction", "upsertAction"].includes(type)) { defaultData.targetType = "internal"; // 기본값: 내부 DB defaultData.fieldMappings = []; @@ -261,6 +268,49 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) { } } + // 메일 발송 노드 + if (type === "emailAction") { + defaultData.displayName = "메일 발송"; + defaultData.smtpConfig = { + host: "", + port: 587, + secure: false, + }; + defaultData.from = ""; + defaultData.to = ""; + defaultData.subject = ""; + defaultData.body = ""; + defaultData.bodyType = "text"; + } + + // 스크립트 실행 노드 + if (type === "scriptAction") { + defaultData.displayName = "스크립트 실행"; + defaultData.scriptType = "python"; + defaultData.executionMode = "inline"; + defaultData.inlineScript = ""; + defaultData.inputMethod = "stdin"; + defaultData.inputFormat = "json"; + defaultData.outputHandling = { + captureStdout: true, + captureStderr: true, + parseOutput: "text", + }; + } + + // HTTP 요청 노드 + if (type === "httpRequestAction") { + defaultData.displayName = "HTTP 요청"; + defaultData.url = ""; + defaultData.method = "GET"; + defaultData.bodyType = "none"; + defaultData.authentication = { type: "none" }; + defaultData.options = { + timeout: 30000, + followRedirects: true, + }; + } + const newNode: any = { id: `node_${Date.now()}`, type, diff --git a/frontend/components/dataflow/node-editor/nodes/EmailActionNode.tsx b/frontend/components/dataflow/node-editor/nodes/EmailActionNode.tsx new file mode 100644 index 00000000..ea8e05dc --- /dev/null +++ b/frontend/components/dataflow/node-editor/nodes/EmailActionNode.tsx @@ -0,0 +1,103 @@ +"use client"; + +/** + * 메일 발송 액션 노드 + * SMTP를 통해 이메일을 발송하는 노드 + */ + +import { memo } from "react"; +import { Handle, Position, NodeProps } from "reactflow"; +import { Mail, Server } from "lucide-react"; +import type { EmailActionNodeData } from "@/types/node-editor"; + +export const EmailActionNode = memo(({ data, selected }: NodeProps) => { + const hasSmtpConfig = data.smtpConfig?.host && data.smtpConfig?.port; + const hasRecipient = data.to && data.to.trim().length > 0; + const hasSubject = data.subject && data.subject.trim().length > 0; + + return ( +
+ {/* 입력 핸들 */} + + + {/* 헤더 */} +
+ +
+
{data.displayName || "메일 발송"}
+
+
+ + {/* 본문 */} +
+ {/* SMTP 설정 상태 */} +
+ + + {hasSmtpConfig ? ( + + {data.smtpConfig.host}:{data.smtpConfig.port} + + ) : ( + SMTP 설정 필요 + )} + +
+ + {/* 수신자 */} +
+ 수신자: + {hasRecipient ? ( + {data.to} + ) : ( + 미설정 + )} +
+ + {/* 제목 */} +
+ 제목: + {hasSubject ? ( + {data.subject} + ) : ( + 미설정 + )} +
+ + {/* 본문 형식 */} +
+ + {data.bodyType === "html" ? "HTML" : "TEXT"} + + {data.attachments && data.attachments.length > 0 && ( + + 첨부 {data.attachments.length}개 + + )} +
+
+ + {/* 출력 핸들 */} + +
+ ); +}); + +EmailActionNode.displayName = "EmailActionNode"; + diff --git a/frontend/components/dataflow/node-editor/nodes/HttpRequestActionNode.tsx b/frontend/components/dataflow/node-editor/nodes/HttpRequestActionNode.tsx new file mode 100644 index 00000000..25677933 --- /dev/null +++ b/frontend/components/dataflow/node-editor/nodes/HttpRequestActionNode.tsx @@ -0,0 +1,124 @@ +"use client"; + +/** + * HTTP 요청 액션 노드 + * REST API를 호출하는 노드 + */ + +import { memo } from "react"; +import { Handle, Position, NodeProps } from "reactflow"; +import { Globe, Lock, Unlock } from "lucide-react"; +import type { HttpRequestActionNodeData } from "@/types/node-editor"; + +// HTTP 메서드별 색상 +const METHOD_COLORS: Record = { + GET: { bg: "bg-green-100", text: "text-green-700" }, + POST: { bg: "bg-blue-100", text: "text-blue-700" }, + PUT: { bg: "bg-orange-100", text: "text-orange-700" }, + PATCH: { bg: "bg-yellow-100", text: "text-yellow-700" }, + DELETE: { bg: "bg-red-100", text: "text-red-700" }, + HEAD: { bg: "bg-gray-100", text: "text-gray-700" }, + OPTIONS: { bg: "bg-purple-100", text: "text-purple-700" }, +}; + +export const HttpRequestActionNode = memo(({ data, selected }: NodeProps) => { + const methodColor = METHOD_COLORS[data.method] || METHOD_COLORS.GET; + const hasUrl = data.url && data.url.trim().length > 0; + const hasAuth = data.authentication?.type && data.authentication.type !== "none"; + + // URL에서 도메인 추출 + const getDomain = (url: string) => { + try { + const urlObj = new URL(url); + return urlObj.hostname; + } catch { + return url; + } + }; + + return ( +
+ {/* 입력 핸들 */} + + + {/* 헤더 */} +
+ +
+
{data.displayName || "HTTP 요청"}
+
+
+ + {/* 본문 */} +
+ {/* 메서드 & 인증 */} +
+ + {data.method} + + {hasAuth ? ( + + + {data.authentication?.type} + + ) : ( + + + 인증없음 + + )} +
+ + {/* URL */} +
+ URL: + {hasUrl ? ( + + {getDomain(data.url)} + + ) : ( + URL 설정 필요 + )} +
+ + {/* 바디 타입 */} + {data.bodyType && data.bodyType !== "none" && ( +
+ Body: + + {data.bodyType.toUpperCase()} + +
+ )} + + {/* 타임아웃 & 재시도 */} +
+ {data.options?.timeout && ( + 타임아웃: {Math.round(data.options.timeout / 1000)}초 + )} + {data.options?.retryCount && data.options.retryCount > 0 && ( + 재시도: {data.options.retryCount}회 + )} +
+
+ + {/* 출력 핸들 */} + +
+ ); +}); + +HttpRequestActionNode.displayName = "HttpRequestActionNode"; + diff --git a/frontend/components/dataflow/node-editor/nodes/ScriptActionNode.tsx b/frontend/components/dataflow/node-editor/nodes/ScriptActionNode.tsx new file mode 100644 index 00000000..c4027047 --- /dev/null +++ b/frontend/components/dataflow/node-editor/nodes/ScriptActionNode.tsx @@ -0,0 +1,118 @@ +"use client"; + +/** + * 스크립트 실행 액션 노드 + * Python, Shell, PowerShell 등 외부 스크립트를 실행하는 노드 + */ + +import { memo } from "react"; +import { Handle, Position, NodeProps } from "reactflow"; +import { Terminal, FileCode, Play } from "lucide-react"; +import type { ScriptActionNodeData } from "@/types/node-editor"; + +// 스크립트 타입별 아이콘 색상 +const SCRIPT_TYPE_COLORS: Record = { + python: { bg: "bg-yellow-100", text: "text-yellow-700", label: "Python" }, + shell: { bg: "bg-green-100", text: "text-green-700", label: "Shell" }, + powershell: { bg: "bg-blue-100", text: "text-blue-700", label: "PowerShell" }, + node: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Node.js" }, + executable: { bg: "bg-gray-100", text: "text-gray-700", label: "실행파일" }, +}; + +export const ScriptActionNode = memo(({ data, selected }: NodeProps) => { + const scriptTypeInfo = SCRIPT_TYPE_COLORS[data.scriptType] || SCRIPT_TYPE_COLORS.executable; + const hasScript = data.executionMode === "inline" ? !!data.inlineScript : !!data.scriptPath; + + return ( +
+ {/* 입력 핸들 */} + + + {/* 헤더 */} +
+ +
+
{data.displayName || "스크립트 실행"}
+
+
+ + {/* 본문 */} +
+ {/* 스크립트 타입 */} +
+ + {scriptTypeInfo.label} + + + {data.executionMode === "inline" ? "인라인" : "파일"} + +
+ + {/* 스크립트 정보 */} +
+ {data.executionMode === "inline" ? ( + <> + + + {hasScript ? ( + + {data.inlineScript!.split("\n").length}줄 스크립트 + + ) : ( + 스크립트 입력 필요 + )} + + + ) : ( + <> + + + {hasScript ? ( + {data.scriptPath} + ) : ( + 파일 경로 필요 + )} + + + )} +
+ + {/* 입력 방식 */} +
+ 입력: + + {data.inputMethod === "stdin" && "표준입력 (stdin)"} + {data.inputMethod === "args" && "명령줄 인자"} + {data.inputMethod === "env" && "환경변수"} + {data.inputMethod === "file" && "파일"} + +
+ + {/* 타임아웃 */} + {data.options?.timeout && ( +
+ 타임아웃: {Math.round(data.options.timeout / 1000)}초 +
+ )} +
+ + {/* 출력 핸들 */} + +
+ ); +}); + +ScriptActionNode.displayName = "ScriptActionNode"; + diff --git a/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx b/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx index 67483e03..41f1a9b4 100644 --- a/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx +++ b/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx @@ -19,6 +19,9 @@ import { AggregateProperties } from "./properties/AggregateProperties"; import { RestAPISourceProperties } from "./properties/RestAPISourceProperties"; import { CommentProperties } from "./properties/CommentProperties"; import { LogProperties } from "./properties/LogProperties"; +import { EmailActionProperties } from "./properties/EmailActionProperties"; +import { ScriptActionProperties } from "./properties/ScriptActionProperties"; +import { HttpRequestActionProperties } from "./properties/HttpRequestActionProperties"; import type { NodeType } from "@/types/node-editor"; export function PropertiesPanel() { @@ -131,6 +134,15 @@ function NodePropertiesRenderer({ node }: { node: any }) { case "log": return ; + case "emailAction": + return ; + + case "scriptAction": + return ; + + case "httpRequestAction": + return ; + default: return (
@@ -165,6 +177,9 @@ function getNodeTypeLabel(type: NodeType): string { updateAction: "UPDATE 액션", deleteAction: "DELETE 액션", upsertAction: "UPSERT 액션", + emailAction: "메일 발송", + scriptAction: "스크립트 실행", + httpRequestAction: "HTTP 요청", comment: "주석", log: "로그", }; diff --git a/frontend/components/dataflow/node-editor/panels/properties/EmailActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/EmailActionProperties.tsx new file mode 100644 index 00000000..b57ba029 --- /dev/null +++ b/frontend/components/dataflow/node-editor/panels/properties/EmailActionProperties.tsx @@ -0,0 +1,431 @@ +"use client"; + +/** + * 메일 발송 노드 속성 편집 + * - 메일관리에서 등록한 계정을 선택하여 발송 + */ + +import { useEffect, useState, useCallback } from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Plus, Trash2, Mail, Server, FileText, Settings, RefreshCw, CheckCircle, AlertCircle, User } from "lucide-react"; +import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; +import { getMailAccounts, type MailAccount } from "@/lib/api/mail"; +import type { EmailActionNodeData } from "@/types/node-editor"; + +interface EmailActionPropertiesProps { + nodeId: string; + data: EmailActionNodeData; +} + +export function EmailActionProperties({ nodeId, data }: EmailActionPropertiesProps) { + const { updateNode } = useFlowEditorStore(); + + // 메일 계정 목록 + const [mailAccounts, setMailAccounts] = useState([]); + const [isLoadingAccounts, setIsLoadingAccounts] = useState(false); + const [accountError, setAccountError] = useState(null); + + // 로컬 상태 + const [displayName, setDisplayName] = useState(data.displayName || "메일 발송"); + + // 계정 선택 + const [selectedAccountId, setSelectedAccountId] = useState(data.accountId || ""); + + // 메일 내용 + const [to, setTo] = useState(data.to || ""); + const [cc, setCc] = useState(data.cc || ""); + const [bcc, setBcc] = useState(data.bcc || ""); + const [subject, setSubject] = useState(data.subject || ""); + const [body, setBody] = useState(data.body || ""); + const [bodyType, setBodyType] = useState<"text" | "html">(data.bodyType || "text"); + + // 고급 설정 + const [replyTo, setReplyTo] = useState(data.replyTo || ""); + const [priority, setPriority] = useState<"high" | "normal" | "low">(data.priority || "normal"); + const [timeout, setTimeout] = useState(data.options?.timeout?.toString() || "30000"); + const [retryCount, setRetryCount] = useState(data.options?.retryCount?.toString() || "3"); + + // 메일 계정 목록 로드 + const loadMailAccounts = useCallback(async () => { + setIsLoadingAccounts(true); + setAccountError(null); + try { + const accounts = await getMailAccounts(); + setMailAccounts(accounts.filter(acc => acc.status === 'active')); + } catch (error) { + console.error("메일 계정 로드 실패:", error); + setAccountError("메일 계정을 불러오는데 실패했습니다"); + } finally { + setIsLoadingAccounts(false); + } + }, []); + + // 컴포넌트 마운트 시 메일 계정 로드 + useEffect(() => { + loadMailAccounts(); + }, [loadMailAccounts]); + + // 데이터 변경 시 로컬 상태 동기화 + useEffect(() => { + setDisplayName(data.displayName || "메일 발송"); + setSelectedAccountId(data.accountId || ""); + setTo(data.to || ""); + setCc(data.cc || ""); + setBcc(data.bcc || ""); + setSubject(data.subject || ""); + setBody(data.body || ""); + setBodyType(data.bodyType || "text"); + setReplyTo(data.replyTo || ""); + setPriority(data.priority || "normal"); + setTimeout(data.options?.timeout?.toString() || "30000"); + setRetryCount(data.options?.retryCount?.toString() || "3"); + }, [data]); + + // 선택된 계정 정보 + const selectedAccount = mailAccounts.find(acc => acc.id === selectedAccountId); + + // 노드 업데이트 함수 + const updateNodeData = useCallback( + (updates: Partial) => { + updateNode(nodeId, { + ...data, + ...updates, + }); + }, + [nodeId, data, updateNode] + ); + + // 표시명 변경 + const handleDisplayNameChange = (value: string) => { + setDisplayName(value); + updateNodeData({ displayName: value }); + }; + + // 계정 선택 변경 + const handleAccountChange = (accountId: string) => { + setSelectedAccountId(accountId); + const account = mailAccounts.find(acc => acc.id === accountId); + updateNodeData({ + accountId, + // 계정의 이메일을 발신자로 자동 설정 + from: account?.email || "" + }); + }; + + // 메일 내용 업데이트 + const updateMailContent = useCallback(() => { + updateNodeData({ + to, + cc: cc || undefined, + bcc: bcc || undefined, + subject, + body, + bodyType, + replyTo: replyTo || undefined, + priority, + }); + }, [to, cc, bcc, subject, body, bodyType, replyTo, priority, updateNodeData]); + + // 옵션 업데이트 + const updateOptions = useCallback(() => { + updateNodeData({ + options: { + timeout: parseInt(timeout) || 30000, + retryCount: parseInt(retryCount) || 3, + }, + }); + }, [timeout, retryCount, updateNodeData]); + + return ( +
+ {/* 표시명 */} +
+ + handleDisplayNameChange(e.target.value)} + placeholder="메일 발송" + className="h-8 text-sm" + /> +
+ + + + + + 계정 + + + + 메일 + + + + 본문 + + + + 옵션 + + + + {/* 계정 선택 탭 */} + +
+
+ + +
+ + {accountError && ( +
+ + {accountError} +
+ )} + + +
+ + {/* 선택된 계정 정보 표시 */} + {selectedAccount && ( + + +
+ + 선택된 계정 +
+
+
이름: {selectedAccount.name}
+
이메일: {selectedAccount.email}
+
SMTP: {selectedAccount.smtpHost}:{selectedAccount.smtpPort}
+
+
+
+ )} + + {!selectedAccount && mailAccounts.length > 0 && ( + + +
+ + 메일 발송을 위해 계정을 선택해주세요. +
+
+
+ )} + + {mailAccounts.length === 0 && !isLoadingAccounts && ( + + +
+
메일 계정 등록 방법:
+
    +
  1. 관리자 메뉴로 이동
  2. +
  3. 메일관리 > 계정관리 선택
  4. +
  5. 새 계정 추가 버튼 클릭
  6. +
  7. SMTP 정보 입력 후 저장
  8. +
+
+
+
+ )} +
+ + {/* 메일 설정 탭 */} + + {/* 발신자는 선택된 계정에서 자동으로 설정됨 */} + {selectedAccount && ( +
+ +
+ {selectedAccount.email} +
+

선택한 계정의 이메일 주소가 자동으로 사용됩니다.

+
+ )} + +
+ + setTo(e.target.value)} + onBlur={updateMailContent} + placeholder="recipient@example.com (쉼표로 구분)" + className="h-8 text-sm" + /> +
+ +
+ + setCc(e.target.value)} + onBlur={updateMailContent} + placeholder="cc@example.com" + className="h-8 text-sm" + /> +
+ +
+ + setBcc(e.target.value)} + onBlur={updateMailContent} + placeholder="bcc@example.com" + className="h-8 text-sm" + /> +
+ +
+ + setReplyTo(e.target.value)} + onBlur={updateMailContent} + placeholder="reply@example.com" + className="h-8 text-sm" + /> +
+ +
+ + +
+
+ + {/* 본문 탭 */} + +
+ + setSubject(e.target.value)} + onBlur={updateMailContent} + placeholder="메일 제목 ({{변수}} 사용 가능)" + className="h-8 text-sm" + /> +
+ +
+ + +
+ +
+ +