From 987120f13bf8f4af1d5709294efddbf3a37339bc Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 9 Dec 2025 10:47:15 +0900 Subject: [PATCH 01/11] =?UTF-8?q?=EC=B0=B8=EC=A1=B0=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EB=85=B8=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dataflow/node-editor/FlowEditor.tsx | 2 - .../node-editor/nodes/ReferenceLookupNode.tsx | 108 --- .../node-editor/panels/PropertiesPanel.tsx | 5 - .../properties/ReferenceLookupProperties.tsx | 706 ------------------ .../node-editor/sidebar/nodePaletteConfig.ts | 8 - frontend/types/node-editor.ts | 31 - 6 files changed, 860 deletions(-) delete mode 100644 frontend/components/dataflow/node-editor/nodes/ReferenceLookupNode.tsx delete mode 100644 frontend/components/dataflow/node-editor/panels/properties/ReferenceLookupProperties.tsx diff --git a/frontend/components/dataflow/node-editor/FlowEditor.tsx b/frontend/components/dataflow/node-editor/FlowEditor.tsx index f74d35aa..333a70c1 100644 --- a/frontend/components/dataflow/node-editor/FlowEditor.tsx +++ b/frontend/components/dataflow/node-editor/FlowEditor.tsx @@ -18,7 +18,6 @@ import { ValidationNotification } from "./ValidationNotification"; import { FlowToolbar } from "./FlowToolbar"; import { TableSourceNode } from "./nodes/TableSourceNode"; import { ExternalDBSourceNode } from "./nodes/ExternalDBSourceNode"; -import { ReferenceLookupNode } from "./nodes/ReferenceLookupNode"; import { ConditionNode } from "./nodes/ConditionNode"; import { InsertActionNode } from "./nodes/InsertActionNode"; import { UpdateActionNode } from "./nodes/UpdateActionNode"; @@ -38,7 +37,6 @@ const nodeTypes = { tableSource: TableSourceNode, externalDBSource: ExternalDBSourceNode, restAPISource: RestAPISourceNode, - referenceLookup: ReferenceLookupNode, // 변환/조건 condition: ConditionNode, dataTransform: DataTransformNode, diff --git a/frontend/components/dataflow/node-editor/nodes/ReferenceLookupNode.tsx b/frontend/components/dataflow/node-editor/nodes/ReferenceLookupNode.tsx deleted file mode 100644 index 181d7dad..00000000 --- a/frontend/components/dataflow/node-editor/nodes/ReferenceLookupNode.tsx +++ /dev/null @@ -1,108 +0,0 @@ -"use client"; - -/** - * 참조 테이블 조회 노드 (내부 DB 전용) - * 다른 테이블에서 데이터를 조회하여 조건 비교나 필드 매핑에 사용 - */ - -import { memo } from "react"; -import { Handle, Position, NodeProps } from "reactflow"; -import { Link2, Database } from "lucide-react"; -import type { ReferenceLookupNodeData } from "@/types/node-editor"; - -export const ReferenceLookupNode = memo(({ data, selected }: NodeProps) => { - return ( -
- {/* 헤더 */} -
- -
-
{data.displayName || "참조 조회"}
-
-
- - {/* 본문 */} -
-
- - 내부 DB 참조 -
- - {/* 참조 테이블 */} - {data.referenceTable && ( -
-
📋 참조 테이블
-
- {data.referenceTableLabel || data.referenceTable} -
-
- )} - - {/* 조인 조건 */} - {data.joinConditions && data.joinConditions.length > 0 && ( -
-
🔗 조인 조건:
-
- {data.joinConditions.map((join, idx) => ( -
- {join.sourceFieldLabel || join.sourceField} - - {join.referenceFieldLabel || join.referenceField} -
- ))} -
-
- )} - - {/* WHERE 조건 */} - {data.whereConditions && data.whereConditions.length > 0 && ( -
-
⚡ WHERE 조건:
-
{data.whereConditions.length}개 조건
-
- )} - - {/* 출력 필드 */} - {data.outputFields && data.outputFields.length > 0 && ( -
-
📤 출력 필드:
-
- {data.outputFields.slice(0, 3).map((field, idx) => ( -
-
- {field.alias} - ← {field.fieldLabel || field.fieldName} -
- ))} - {data.outputFields.length > 3 && ( -
... 외 {data.outputFields.length - 3}개
- )} -
-
- )} -
- - {/* 입력 핸들 (왼쪽) */} - - - {/* 출력 핸들 (오른쪽) */} - -
- ); -}); - -ReferenceLookupNode.displayName = "ReferenceLookupNode"; - - diff --git a/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx b/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx index cf7c7e6e..67483e03 100644 --- a/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx +++ b/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx @@ -8,7 +8,6 @@ import { X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; import { TableSourceProperties } from "./properties/TableSourceProperties"; -import { ReferenceLookupProperties } from "./properties/ReferenceLookupProperties"; import { InsertActionProperties } from "./properties/InsertActionProperties"; import { ConditionProperties } from "./properties/ConditionProperties"; import { UpdateActionProperties } from "./properties/UpdateActionProperties"; @@ -99,9 +98,6 @@ function NodePropertiesRenderer({ node }: { node: any }) { case "tableSource": return ; - case "referenceLookup": - return ; - case "insertAction": return ; @@ -161,7 +157,6 @@ function getNodeTypeLabel(type: NodeType): string { tableSource: "테이블 소스", externalDBSource: "외부 DB 소스", restAPISource: "REST API 소스", - referenceLookup: "참조 조회", condition: "조건 분기", fieldMapping: "필드 매핑", dataTransform: "데이터 변환", diff --git a/frontend/components/dataflow/node-editor/panels/properties/ReferenceLookupProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/ReferenceLookupProperties.tsx deleted file mode 100644 index b2bb51e0..00000000 --- a/frontend/components/dataflow/node-editor/panels/properties/ReferenceLookupProperties.tsx +++ /dev/null @@ -1,706 +0,0 @@ -"use client"; - -/** - * 참조 테이블 조회 노드 속성 편집 - */ - -import { useEffect, useState, useCallback } from "react"; -import { Plus, Trash2, Search, Check, ChevronsUpDown } from "lucide-react"; -import { Label } from "@/components/ui/label"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; -import { cn } from "@/lib/utils"; -import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; -import type { ReferenceLookupNodeData } from "@/types/node-editor"; -import { tableTypeApi } from "@/lib/api/screen"; - -// 필드 정의 -interface FieldDefinition { - name: string; - label?: string; - type?: string; -} - -interface ReferenceLookupPropertiesProps { - nodeId: string; - data: ReferenceLookupNodeData; -} - -const OPERATORS = [ - { value: "=", label: "같음 (=)" }, - { value: "!=", label: "같지 않음 (≠)" }, - { value: ">", label: "보다 큼 (>)" }, - { value: "<", label: "보다 작음 (<)" }, - { value: ">=", label: "크거나 같음 (≥)" }, - { value: "<=", label: "작거나 같음 (≤)" }, - { value: "LIKE", label: "포함 (LIKE)" }, - { value: "IN", label: "IN" }, -] as const; - -export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPropertiesProps) { - const { updateNode, nodes, edges } = useFlowEditorStore(); - - // 상태 - const [displayName, setDisplayName] = useState(data.displayName || "참조 조회"); - const [referenceTable, setReferenceTable] = useState(data.referenceTable || ""); - const [referenceTableLabel, setReferenceTableLabel] = useState(data.referenceTableLabel || ""); - const [joinConditions, setJoinConditions] = useState(data.joinConditions || []); - const [whereConditions, setWhereConditions] = useState(data.whereConditions || []); - const [outputFields, setOutputFields] = useState(data.outputFields || []); - - // 소스 필드 수집 - const [sourceFields, setSourceFields] = useState([]); - - // 참조 테이블 관련 - const [tables, setTables] = useState([]); - const [tablesLoading, setTablesLoading] = useState(false); - const [tablesOpen, setTablesOpen] = useState(false); - const [referenceColumns, setReferenceColumns] = useState([]); - const [columnsLoading, setColumnsLoading] = useState(false); - - // Combobox 열림 상태 관리 - const [whereFieldOpenState, setWhereFieldOpenState] = useState([]); - - // 데이터 변경 시 로컬 상태 동기화 - useEffect(() => { - setDisplayName(data.displayName || "참조 조회"); - setReferenceTable(data.referenceTable || ""); - setReferenceTableLabel(data.referenceTableLabel || ""); - setJoinConditions(data.joinConditions || []); - setWhereConditions(data.whereConditions || []); - setOutputFields(data.outputFields || []); - }, [data]); - - // whereConditions 변경 시 whereFieldOpenState 초기화 - useEffect(() => { - setWhereFieldOpenState(new Array(whereConditions.length).fill(false)); - }, [whereConditions.length]); - - // 🔍 소스 필드 수집 (업스트림 노드에서) - useEffect(() => { - const incomingEdges = edges.filter((e) => e.target === nodeId); - const fields: FieldDefinition[] = []; - - for (const edge of incomingEdges) { - const sourceNode = nodes.find((n) => n.id === edge.source); - if (!sourceNode) continue; - - const sourceData = sourceNode.data as any; - - if (sourceNode.type === "tableSource" && sourceData.fields) { - fields.push(...sourceData.fields); - } else if (sourceNode.type === "externalDBSource" && sourceData.outputFields) { - fields.push(...sourceData.outputFields); - } - } - - setSourceFields(fields); - }, [nodeId, nodes, edges]); - - // 📊 테이블 목록 로드 - useEffect(() => { - loadTables(); - }, []); - - const loadTables = async () => { - setTablesLoading(true); - try { - const data = await tableTypeApi.getTables(); - setTables(data); - } catch (error) { - console.error("테이블 로드 실패:", error); - } finally { - setTablesLoading(false); - } - }; - - // 📋 참조 테이블 컬럼 로드 - useEffect(() => { - if (referenceTable) { - loadReferenceColumns(); - } else { - setReferenceColumns([]); - } - }, [referenceTable]); - - const loadReferenceColumns = async () => { - if (!referenceTable) return; - - setColumnsLoading(true); - try { - const cols = await tableTypeApi.getColumns(referenceTable); - const formatted = cols.map((col: any) => ({ - name: col.columnName, - type: col.dataType, - label: col.displayName || col.columnName, - })); - setReferenceColumns(formatted); - } catch (error) { - console.error("컬럼 로드 실패:", error); - setReferenceColumns([]); - } finally { - setColumnsLoading(false); - } - }; - - // 테이블 선택 핸들러 - const handleTableSelect = (tableName: string) => { - const selectedTable = tables.find((t) => t.tableName === tableName); - if (selectedTable) { - setReferenceTable(tableName); - setReferenceTableLabel(selectedTable.label); - setTablesOpen(false); - - // 기존 설정 초기화 - setJoinConditions([]); - setWhereConditions([]); - setOutputFields([]); - } - }; - - // 조인 조건 추가 - const handleAddJoinCondition = () => { - setJoinConditions([ - ...joinConditions, - { - sourceField: "", - referenceField: "", - }, - ]); - }; - - const handleRemoveJoinCondition = (index: number) => { - setJoinConditions(joinConditions.filter((_, i) => i !== index)); - }; - - const handleJoinConditionChange = (index: number, field: string, value: any) => { - const newConditions = [...joinConditions]; - newConditions[index] = { ...newConditions[index], [field]: value }; - - // 라벨도 함께 저장 - if (field === "sourceField") { - const sourceField = sourceFields.find((f) => f.name === value); - newConditions[index].sourceFieldLabel = sourceField?.label || value; - } else if (field === "referenceField") { - const refField = referenceColumns.find((f) => f.name === value); - newConditions[index].referenceFieldLabel = refField?.label || value; - } - - setJoinConditions(newConditions); - }; - - // WHERE 조건 추가 - const handleAddWhereCondition = () => { - const newConditions = [ - ...whereConditions, - { - field: "", - operator: "=", - value: "", - valueType: "static", - }, - ]; - setWhereConditions(newConditions); - setWhereFieldOpenState(new Array(newConditions.length).fill(false)); - }; - - const handleRemoveWhereCondition = (index: number) => { - const newConditions = whereConditions.filter((_, i) => i !== index); - setWhereConditions(newConditions); - setWhereFieldOpenState(new Array(newConditions.length).fill(false)); - }; - - const handleWhereConditionChange = (index: number, field: string, value: any) => { - const newConditions = [...whereConditions]; - newConditions[index] = { ...newConditions[index], [field]: value }; - - // 라벨도 함께 저장 - if (field === "field") { - const refField = referenceColumns.find((f) => f.name === value); - newConditions[index].fieldLabel = refField?.label || value; - } - - setWhereConditions(newConditions); - }; - - // 출력 필드 추가 - const handleAddOutputField = () => { - setOutputFields([ - ...outputFields, - { - fieldName: "", - alias: "", - }, - ]); - }; - - const handleRemoveOutputField = (index: number) => { - setOutputFields(outputFields.filter((_, i) => i !== index)); - }; - - const handleOutputFieldChange = (index: number, field: string, value: any) => { - const newFields = [...outputFields]; - newFields[index] = { ...newFields[index], [field]: value }; - - // 라벨도 함께 저장 - if (field === "fieldName") { - const refField = referenceColumns.find((f) => f.name === value); - newFields[index].fieldLabel = refField?.label || value; - // alias 자동 설정 - if (!newFields[index].alias) { - newFields[index].alias = `ref_${value}`; - } - } - - setOutputFields(newFields); - }; - - const handleSave = () => { - updateNode(nodeId, { - displayName, - referenceTable, - referenceTableLabel, - joinConditions, - whereConditions, - outputFields, - }); - }; - - const selectedTableLabel = tables.find((t) => t.tableName === referenceTable)?.label || referenceTable; - - return ( -
-
- {/* 기본 정보 */} -
-

기본 정보

- -
-
- - setDisplayName(e.target.value)} - className="mt-1" - placeholder="노드 표시 이름" - /> -
- - {/* 참조 테이블 선택 */} -
- - - - - - - - - - 검색 결과가 없습니다. - - - {tables.map((table) => ( - handleTableSelect(table.tableName)} - className="cursor-pointer" - > - -
- {table.label} - {table.label !== table.tableName && ( - {table.tableName} - )} -
-
- ))} -
-
-
-
-
-
-
-
-
- - {/* 조인 조건 */} -
-
-

조인 조건 (FK 매핑)

- -
- - {joinConditions.length > 0 ? ( -
- {joinConditions.map((condition, index) => ( -
-
- 조인 #{index + 1} - -
- -
-
- - -
- -
- - -
-
-
- ))} -
- ) : ( -
- 조인 조건을 추가하세요 (필수) -
- )} -
- - {/* WHERE 조건 */} -
-
-

WHERE 조건 (선택사항)

- -
- - {whereConditions.length > 0 && ( -
- {whereConditions.map((condition, index) => ( -
-
- WHERE #{index + 1} - -
- -
- {/* 필드 - Combobox */} -
- - { - const newState = [...whereFieldOpenState]; - newState[index] = open; - setWhereFieldOpenState(newState); - }} - > - - - - - - - - 필드를 찾을 수 없습니다. - - {referenceColumns.map((field) => ( - { - handleWhereConditionChange(index, "field", currentValue); - const newState = [...whereFieldOpenState]; - newState[index] = false; - setWhereFieldOpenState(newState); - }} - className="text-xs sm:text-sm" - > - -
- {field.label || field.name} - {field.type && ( - {field.type} - )} -
-
- ))} -
-
-
-
-
-
- -
- - -
- -
- - -
- -
- - {condition.valueType === "field" ? ( - - ) : ( - handleWhereConditionChange(index, "value", e.target.value)} - placeholder="비교할 값" - className="mt-1 h-8 text-xs" - /> - )} -
-
-
- ))} -
- )} -
- - {/* 출력 필드 */} -
-
-

출력 필드

- -
- - {outputFields.length > 0 ? ( -
- {outputFields.map((field, index) => ( -
-
- 필드 #{index + 1} - -
- -
-
- - -
- -
- - handleOutputFieldChange(index, "alias", e.target.value)} - placeholder="ref_field_name" - className="mt-1 h-8 text-xs" - /> -
-
-
- ))} -
- ) : ( -
- 출력 필드를 추가하세요 (필수) -
- )} -
- - {/* 저장 버튼 */} - - {/* 안내 */} -
-
- 🔗 조인 조건: 소스 데이터와 참조 테이블을 연결하는 키 (예: customer_id → id) -
-
- ⚡ WHERE 조건: 참조 테이블에서 특정 조건의 데이터만 가져오기 (예: grade = 'VIP') -
-
- 📤 출력 필드: 참조 테이블에서 가져올 필드 선택 (별칭으로 결과에 추가됨) -
-
-
-
- ); -} diff --git a/frontend/components/dataflow/node-editor/sidebar/nodePaletteConfig.ts b/frontend/components/dataflow/node-editor/sidebar/nodePaletteConfig.ts index 2ff31689..0cc77705 100644 --- a/frontend/components/dataflow/node-editor/sidebar/nodePaletteConfig.ts +++ b/frontend/components/dataflow/node-editor/sidebar/nodePaletteConfig.ts @@ -32,14 +32,6 @@ export const NODE_PALETTE: NodePaletteItem[] = [ category: "source", color: "#10B981", // 초록색 }, - { - type: "referenceLookup", - label: "참조 조회", - icon: "", - description: "다른 테이블에서 데이터를 조회합니다 (내부 DB 전용)", - category: "source", - color: "#A855F7", // 보라색 - }, // ======================================================================== // 변환/조건 diff --git a/frontend/types/node-editor.ts b/frontend/types/node-editor.ts index fc5adb89..9b6ce969 100644 --- a/frontend/types/node-editor.ts +++ b/frontend/types/node-editor.ts @@ -12,7 +12,6 @@ export type NodeType = | "tableSource" // 테이블 소스 | "externalDBSource" // 외부 DB 소스 | "restAPISource" // REST API 소스 - | "referenceLookup" // 참조 테이블 조회 (내부 DB 전용) | "condition" // 조건 분기 | "dataTransform" // 데이터 변환 | "aggregate" // 집계 노드 (SUM, COUNT, AVG 등) @@ -92,35 +91,6 @@ export interface RestAPISourceNodeData { displayName?: string; } -// 참조 테이블 조회 노드 (내부 DB 전용) -export interface ReferenceLookupNodeData { - type: "referenceLookup"; - referenceTable: string; // 참조할 테이블명 - referenceTableLabel?: string; // 테이블 라벨 - joinConditions: Array<{ - // 조인 조건 (FK 매핑) - sourceField: string; // 소스 데이터의 필드 - sourceFieldLabel?: string; - referenceField: string; // 참조 테이블의 필드 - referenceFieldLabel?: string; - }>; - whereConditions?: Array<{ - // 추가 WHERE 조건 - field: string; - fieldLabel?: string; - operator: string; - value: any; - valueType?: "static" | "field"; // 고정값 또는 소스 필드 참조 - }>; - outputFields: Array<{ - // 가져올 필드들 - fieldName: string; // 참조 테이블의 컬럼명 - fieldLabel?: string; - alias: string; // 결과 데이터에서 사용할 이름 - }>; - displayName?: string; -} - // 조건 분기 노드 export interface ConditionNodeData { conditions: Array<{ @@ -431,7 +401,6 @@ export type NodeData = | TableSourceNodeData | ExternalDBSourceNodeData | RestAPISourceNodeData - | ReferenceLookupNodeData | ConditionNodeData | FieldMappingNodeData | DataTransformNodeData From bb98e9319f474356b906deb511fdf0fdb113f35b Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 9 Dec 2025 12:13:30 +0900 Subject: [PATCH 02/11] =?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" + /> +
+ +
+ + +
+ +
+ +