액션 노드들 로직 구현

This commit is contained in:
kjs 2025-10-02 17:51:15 +09:00
parent 37e018b33c
commit 258bd80201
13 changed files with 4504 additions and 525 deletions

View File

@ -103,15 +103,34 @@ export class OracleConnector implements DatabaseConnector {
try { try {
const startTime = Date.now(); const startTime = Date.now();
// 쿼리 타입 확인 (DML인지 SELECT인지) // 쿼리 타입 확인
const isDML = /^\s*(INSERT|UPDATE|DELETE|MERGE)/i.test(query); const isDML = /^\s*(INSERT|UPDATE|DELETE|MERGE)/i.test(query);
const isCOMMIT = /^\s*COMMIT/i.test(query);
const isROLLBACK = /^\s*ROLLBACK/i.test(query);
// 🔥 COMMIT/ROLLBACK 명령은 직접 실행
if (isCOMMIT || isROLLBACK) {
if (isCOMMIT) {
await this.connection!.commit();
console.log("✅ Oracle COMMIT 실행됨");
} else {
await this.connection!.rollback();
console.log("⚠️ Oracle ROLLBACK 실행됨");
}
return {
rows: [],
rowCount: 0,
fields: [],
affectedRows: 0,
};
}
// Oracle XE 21c 쿼리 실행 옵션 // Oracle XE 21c 쿼리 실행 옵션
const options: any = { const options: any = {
outFormat: (oracledb as any).OUT_FORMAT_OBJECT, // OBJECT format outFormat: (oracledb as any).OUT_FORMAT_OBJECT, // OBJECT format
maxRows: 10000, // XE 제한 고려 maxRows: 10000, // XE 제한 고려
fetchArraySize: 100, fetchArraySize: 100,
autoCommit: isDML, // ✅ DML 쿼리는 자동 커밋 autoCommit: false, // 🔥 수동으로 COMMIT 제어하도록 변경
}; };
console.log("Oracle 쿼리 실행:", { console.log("Oracle 쿼리 실행:", {

View File

@ -1,6 +1,10 @@
import axios, { AxiosInstance, AxiosResponse } from 'axios'; import axios, { AxiosInstance, AxiosResponse } from "axios";
import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector'; import {
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes'; DatabaseConnector,
ConnectionConfig,
QueryResult,
} from "../interfaces/DatabaseConnector";
import { ConnectionTestResult, TableInfo } from "../types/externalDbTypes";
export interface RestApiConfig { export interface RestApiConfig {
baseUrl: string; baseUrl: string;
@ -26,10 +30,10 @@ export class RestApiConnector implements DatabaseConnector {
baseURL: config.baseUrl, baseURL: config.baseUrl,
timeout: config.timeout || 30000, timeout: config.timeout || 30000,
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
'Authorization': `Bearer ${config.apiKey}`, Authorization: `Bearer ${config.apiKey}`,
'Accept': 'application/json' Accept: "application/json",
} },
}); });
// 요청/응답 인터셉터 설정 // 요청/응답 인터셉터 설정
@ -40,11 +44,13 @@ export class RestApiConnector implements DatabaseConnector {
// 요청 인터셉터 // 요청 인터셉터
this.httpClient.interceptors.request.use( this.httpClient.interceptors.request.use(
(config) => { (config) => {
console.log(`[RestApiConnector] 요청: ${config.method?.toUpperCase()} ${config.url}`); console.log(
`[RestApiConnector] 요청: ${config.method?.toUpperCase()} ${config.url}`
);
return config; return config;
}, },
(error) => { (error) => {
console.error('[RestApiConnector] 요청 오류:', error); console.error("[RestApiConnector] 요청 오류:", error);
return Promise.reject(error); return Promise.reject(error);
} }
); );
@ -52,11 +58,17 @@ export class RestApiConnector implements DatabaseConnector {
// 응답 인터셉터 // 응답 인터셉터
this.httpClient.interceptors.response.use( this.httpClient.interceptors.response.use(
(response) => { (response) => {
console.log(`[RestApiConnector] 응답: ${response.status} ${response.statusText}`); console.log(
`[RestApiConnector] 응답: ${response.status} ${response.statusText}`
);
return response; return response;
}, },
(error) => { (error) => {
console.error('[RestApiConnector] 응답 오류:', error.response?.status, error.response?.statusText); console.error(
"[RestApiConnector] 응답 오류:",
error.response?.status,
error.response?.statusText
);
return Promise.reject(error); return Promise.reject(error);
} }
); );
@ -65,16 +77,23 @@ export class RestApiConnector implements DatabaseConnector {
async connect(): Promise<void> { async connect(): Promise<void> {
try { try {
// 연결 테스트 - 기본 엔드포인트 호출 // 연결 테스트 - 기본 엔드포인트 호출
await this.httpClient.get('/health', { timeout: 5000 }); await this.httpClient.get("/health", { timeout: 5000 });
console.log(`[RestApiConnector] 연결 성공: ${this.config.baseUrl}`); console.log(`[RestApiConnector] 연결 성공: ${this.config.baseUrl}`);
} catch (error) { } catch (error) {
// health 엔드포인트가 없을 수 있으므로 404는 정상으로 처리 // health 엔드포인트가 없을 수 있으므로 404는 정상으로 처리
if (axios.isAxiosError(error) && error.response?.status === 404) { if (axios.isAxiosError(error) && error.response?.status === 404) {
console.log(`[RestApiConnector] 연결 성공 (health 엔드포인트 없음): ${this.config.baseUrl}`); console.log(
`[RestApiConnector] 연결 성공 (health 엔드포인트 없음): ${this.config.baseUrl}`
);
return; return;
} }
console.error(`[RestApiConnector] 연결 실패: ${this.config.baseUrl}`, error); console.error(
throw new Error(`REST API 연결 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`); `[RestApiConnector] 연결 실패: ${this.config.baseUrl}`,
error
);
throw new Error(
`REST API 연결 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`
);
} }
} }
@ -88,39 +107,55 @@ export class RestApiConnector implements DatabaseConnector {
await this.connect(); await this.connect();
return { return {
success: true, success: true,
message: 'REST API 연결이 성공했습니다.', message: "REST API 연결이 성공했습니다.",
details: { details: {
response_time: Date.now() response_time: Date.now(),
} },
}; };
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
message: error instanceof Error ? error.message : 'REST API 연결에 실패했습니다.', message:
error instanceof Error
? error.message
: "REST API 연결에 실패했습니다.",
details: { details: {
response_time: Date.now() response_time: Date.now(),
} },
}; };
} }
} }
async executeQuery(endpoint: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET', data?: any): Promise<QueryResult> { // 🔥 DatabaseConnector 인터페이스 호환용 executeQuery (사용하지 않음)
async executeQuery(query: string, params?: any[]): Promise<QueryResult> {
// REST API는 executeRequest를 사용해야 함
throw new Error(
"REST API Connector는 executeQuery를 지원하지 않습니다. executeRequest를 사용하세요."
);
}
// 🔥 실제 REST API 요청을 위한 메서드
async executeRequest(
endpoint: string,
method: "GET" | "POST" | "PUT" | "DELETE" = "GET",
data?: any
): Promise<QueryResult> {
try { try {
const startTime = Date.now(); const startTime = Date.now();
let response: AxiosResponse; let response: AxiosResponse;
// HTTP 메서드에 따른 요청 실행 // HTTP 메서드에 따른 요청 실행
switch (method.toUpperCase()) { switch (method.toUpperCase()) {
case 'GET': case "GET":
response = await this.httpClient.get(endpoint); response = await this.httpClient.get(endpoint);
break; break;
case 'POST': case "POST":
response = await this.httpClient.post(endpoint, data); response = await this.httpClient.post(endpoint, data);
break; break;
case 'PUT': case "PUT":
response = await this.httpClient.put(endpoint, data); response = await this.httpClient.put(endpoint, data);
break; break;
case 'DELETE': case "DELETE":
response = await this.httpClient.delete(endpoint); response = await this.httpClient.delete(endpoint);
break; break;
default: default:
@ -133,21 +168,36 @@ export class RestApiConnector implements DatabaseConnector {
console.log(`[RestApiConnector] 원본 응답 데이터:`, { console.log(`[RestApiConnector] 원본 응답 데이터:`, {
type: typeof responseData, type: typeof responseData,
isArray: Array.isArray(responseData), isArray: Array.isArray(responseData),
keys: typeof responseData === 'object' ? Object.keys(responseData) : 'not object', keys:
responseData: responseData typeof responseData === "object"
? Object.keys(responseData)
: "not object",
responseData: responseData,
}); });
// 응답 데이터 처리 // 응답 데이터 처리
let rows: any[]; let rows: any[];
if (Array.isArray(responseData)) { if (Array.isArray(responseData)) {
rows = responseData; rows = responseData;
} else if (responseData && responseData.data && Array.isArray(responseData.data)) { } else if (
responseData &&
responseData.data &&
Array.isArray(responseData.data)
) {
// API 응답이 {success: true, data: [...]} 형태인 경우 // API 응답이 {success: true, data: [...]} 형태인 경우
rows = responseData.data; rows = responseData.data;
} else if (responseData && responseData.data && typeof responseData.data === 'object') { } else if (
responseData &&
responseData.data &&
typeof responseData.data === "object"
) {
// API 응답이 {success: true, data: {...}} 형태인 경우 (단일 객체) // API 응답이 {success: true, data: {...}} 형태인 경우 (단일 객체)
rows = [responseData.data]; rows = [responseData.data];
} else if (responseData && typeof responseData === 'object' && !Array.isArray(responseData)) { } else if (
responseData &&
typeof responseData === "object" &&
!Array.isArray(responseData)
) {
// 단일 객체 응답인 경우 // 단일 객체 응답인 경우
rows = [responseData]; rows = [responseData];
} else { } else {
@ -156,8 +206,8 @@ export class RestApiConnector implements DatabaseConnector {
console.log(`[RestApiConnector] 처리된 rows:`, { console.log(`[RestApiConnector] 처리된 rows:`, {
rowsLength: rows.length, rowsLength: rows.length,
firstRow: rows.length > 0 ? rows[0] : 'no data', firstRow: rows.length > 0 ? rows[0] : "no data",
allRows: rows allRows: rows,
}); });
console.log(`[RestApiConnector] API 호출 결과:`, { console.log(`[RestApiConnector] API 호출 결과:`, {
@ -165,22 +215,32 @@ export class RestApiConnector implements DatabaseConnector {
method, method,
status: response.status, status: response.status,
rowCount: rows.length, rowCount: rows.length,
executionTime: `${executionTime}ms` executionTime: `${executionTime}ms`,
}); });
return { return {
rows: rows, rows: rows,
rowCount: rows.length, rowCount: rows.length,
fields: rows.length > 0 ? Object.keys(rows[0]).map(key => ({ name: key, type: 'string' })) : [] fields:
rows.length > 0
? Object.keys(rows[0]).map((key) => ({ name: key, type: "string" }))
: [],
}; };
} catch (error) { } catch (error) {
console.error(`[RestApiConnector] API 호출 오류 (${method} ${endpoint}):`, error); console.error(
`[RestApiConnector] API 호출 오류 (${method} ${endpoint}):`,
error
);
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
throw new Error(`REST API 호출 실패: ${error.response?.status} ${error.response?.statusText}`); throw new Error(
`REST API 호출 실패: ${error.response?.status} ${error.response?.statusText}`
);
} }
throw new Error(`REST API 호출 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`); throw new Error(
`REST API 호출 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`
);
} }
} }
@ -189,20 +249,20 @@ export class RestApiConnector implements DatabaseConnector {
// 일반적인 REST API 엔드포인트들을 반환 // 일반적인 REST API 엔드포인트들을 반환
return [ return [
{ {
table_name: '/api/users', table_name: "/api/users",
columns: [], columns: [],
description: '사용자 정보 API' description: "사용자 정보 API",
}, },
{ {
table_name: '/api/data', table_name: "/api/data",
columns: [], columns: [],
description: '기본 데이터 API' description: "기본 데이터 API",
}, },
{ {
table_name: '/api/custom', table_name: "/api/custom",
columns: [], columns: [],
description: '사용자 정의 엔드포인트' description: "사용자 정의 엔드포인트",
} },
]; ];
} }
@ -213,22 +273,25 @@ export class RestApiConnector implements DatabaseConnector {
async getColumns(endpoint: string): Promise<any[]> { async getColumns(endpoint: string): Promise<any[]> {
try { try {
// GET 요청으로 샘플 데이터를 가져와서 필드 구조 파악 // GET 요청으로 샘플 데이터를 가져와서 필드 구조 파악
const result = await this.executeQuery(endpoint, 'GET'); const result = await this.executeRequest(endpoint, "GET");
if (result.rows.length > 0) { if (result.rows.length > 0) {
const sampleRow = result.rows[0]; const sampleRow = result.rows[0];
return Object.keys(sampleRow).map(key => ({ return Object.keys(sampleRow).map((key) => ({
column_name: key, column_name: key,
data_type: typeof sampleRow[key], data_type: typeof sampleRow[key],
is_nullable: 'YES', is_nullable: "YES",
column_default: null, column_default: null,
description: `${key} 필드` description: `${key} 필드`,
})); }));
} }
return []; return [];
} catch (error) { } catch (error) {
console.error(`[RestApiConnector] 컬럼 정보 조회 오류 (${endpoint}):`, error); console.error(
`[RestApiConnector] 컬럼 정보 조회 오류 (${endpoint}):`,
error
);
return []; return [];
} }
} }
@ -238,24 +301,29 @@ export class RestApiConnector implements DatabaseConnector {
} }
// REST API 전용 메서드들 // REST API 전용 메서드들
async getData(endpoint: string, params?: Record<string, any>): Promise<any[]> { async getData(
const queryString = params ? '?' + new URLSearchParams(params).toString() : ''; endpoint: string,
const result = await this.executeQuery(endpoint + queryString, 'GET'); params?: Record<string, any>
): Promise<any[]> {
const queryString = params
? "?" + new URLSearchParams(params).toString()
: "";
const result = await this.executeRequest(endpoint + queryString, "GET");
return result.rows; return result.rows;
} }
async postData(endpoint: string, data: any): Promise<any> { async postData(endpoint: string, data: any): Promise<any> {
const result = await this.executeQuery(endpoint, 'POST', data); const result = await this.executeRequest(endpoint, "POST", data);
return result.rows[0]; return result.rows[0];
} }
async putData(endpoint: string, data: any): Promise<any> { async putData(endpoint: string, data: any): Promise<any> {
const result = await this.executeQuery(endpoint, 'PUT', data); const result = await this.executeRequest(endpoint, "PUT", data);
return result.rows[0]; return result.rows[0];
} }
async deleteData(endpoint: string): Promise<any> { async deleteData(endpoint: string): Promise<any> {
const result = await this.executeQuery(endpoint, 'DELETE'); const result = await this.executeRequest(endpoint, "DELETE");
return result.rows[0]; return result.rows[0];
} }
} }

View File

@ -1,4 +1,4 @@
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes'; import { ConnectionTestResult, TableInfo } from "../types/externalDbTypes";
export interface ConnectionConfig { export interface ConnectionConfig {
host: string; host: string;
@ -15,13 +15,15 @@ export interface QueryResult {
rows: any[]; rows: any[];
rowCount?: number; rowCount?: number;
fields?: any[]; fields?: any[];
affectedRows?: number; // MySQL/MariaDB용
length?: number; // 배열 형태로 반환되는 경우
} }
export interface DatabaseConnector { export interface DatabaseConnector {
connect(): Promise<void>; connect(): Promise<void>;
disconnect(): Promise<void>; disconnect(): Promise<void>;
testConnection(): Promise<ConnectionTestResult>; testConnection(): Promise<ConnectionTestResult>;
executeQuery(query: string): Promise<QueryResult>; executeQuery(query: string, params?: any[]): Promise<QueryResult>; // params 추가
getTables(): Promise<TableInfo[]>; getTables(): Promise<TableInfo[]>;
getColumns(tableName: string): Promise<any[]>; // 특정 테이블의 컬럼 정보 조회 getColumns(tableName: string): Promise<any[]>; // 특정 테이블의 컬럼 정보 조회
} }

View File

@ -895,13 +895,18 @@ export class BatchExternalDbService {
); );
} }
// 데이터 조회 // 데이터 조회 (REST API는 executeRequest 사용)
const result = await connector.executeQuery(finalEndpoint, method); let result;
if ((connector as any).executeRequest) {
result = await (connector as any).executeRequest(finalEndpoint, method);
} else {
result = await connector.executeQuery(finalEndpoint);
}
let data = result.rows; let data = result.rows;
// 컬럼 필터링 (지정된 컬럼만 추출) // 컬럼 필터링 (지정된 컬럼만 추출)
if (columns && columns.length > 0) { if (columns && columns.length > 0) {
data = data.map((row) => { data = data.map((row: any) => {
const filteredRow: any = {}; const filteredRow: any = {};
columns.forEach((col) => { columns.forEach((col) => {
if (row.hasOwnProperty(col)) { if (row.hasOwnProperty(col)) {
@ -1039,7 +1044,16 @@ export class BatchExternalDbService {
); );
console.log(`[BatchExternalDbService] 전송할 데이터:`, requestData); console.log(`[BatchExternalDbService] 전송할 데이터:`, requestData);
await connector.executeQuery(finalEndpoint, method, requestData); // REST API는 executeRequest 사용
if ((connector as any).executeRequest) {
await (connector as any).executeRequest(
finalEndpoint,
method,
requestData
);
} else {
await connector.executeQuery(finalEndpoint);
}
successCount++; successCount++;
} catch (error) { } catch (error) {
console.error(`REST API 레코드 전송 실패:`, error); console.error(`REST API 레코드 전송 실패:`, error);
@ -1104,7 +1118,12 @@ export class BatchExternalDbService {
); );
console.log(`[BatchExternalDbService] 전송할 데이터:`, record); console.log(`[BatchExternalDbService] 전송할 데이터:`, record);
await connector.executeQuery(endpoint, method, record); // REST API는 executeRequest 사용
if ((connector as any).executeRequest) {
await (connector as any).executeRequest(endpoint, method, record);
} else {
await connector.executeQuery(endpoint);
}
successCount++; successCount++;
} catch (error) { } catch (error) {
console.error(`REST API 레코드 전송 실패:`, error); console.error(`REST API 레코드 전송 실패:`, error);

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,332 @@
# 액션 노드 타겟 선택 시스템 개선 계획
## 📋 현재 문제점
### 1. 타겟 타입 구분 부재
- INSERT/UPDATE/DELETE/UPSERT 액션 노드가 타겟 테이블만 선택 가능
- 내부 DB인지, 외부 DB인지, REST API인지 구분 없음
- 실행 시 항상 내부 DB로만 동작
### 2. 외부 시스템 연동 불가
- 외부 DB에 데이터 저장 불가
- 외부 REST API 호출 불가
- 하이브리드 플로우 구성 불가 (내부 → 외부 데이터 전송)
---
## 🎯 개선 목표
액션 노드에서 다음 3가지 타겟 타입을 선택할 수 있도록 개선:
### 1. 내부 데이터베이스 (Internal DB)
- 현재 시스템의 PostgreSQL
- 기존 동작 유지
### 2. 외부 데이터베이스 (External DB)
- 외부 커넥션 관리에서 설정한 DB
- PostgreSQL, MySQL, Oracle, MSSQL, MariaDB 지원
### 3. REST API
- 외부 REST API 호출
- HTTP 메서드: POST, PUT, PATCH, DELETE
- 인증: None, Basic, Bearer Token, API Key
---
## 📐 타입 정의 확장
### TargetType 추가
```typescript
export type TargetType = "internal" | "external" | "api";
export interface BaseActionNodeData {
displayName: string;
targetType: TargetType; // 🔥 새로 추가
// targetType === "internal"
targetTable?: string;
targetTableLabel?: string;
// targetType === "external"
externalConnectionId?: number;
externalConnectionName?: string;
externalDbType?: string;
externalTargetTable?: string;
externalTargetSchema?: string;
// targetType === "api"
apiEndpoint?: string;
apiMethod?: "POST" | "PUT" | "PATCH" | "DELETE";
apiAuthType?: "none" | "basic" | "bearer" | "apikey";
apiAuthConfig?: {
username?: string;
password?: string;
token?: string;
apiKey?: string;
apiKeyHeader?: string;
};
apiHeaders?: Record<string, string>;
apiBodyTemplate?: string; // JSON 템플릿
}
```
---
## 🎨 UI 설계
### 1. 타겟 타입 선택 (공통)
```
┌─────────────────────────────────────┐
│ 타겟 타입 │
│ ○ 내부 데이터베이스 (기본) │
│ ○ 외부 데이터베이스 │
│ ○ REST API │
└─────────────────────────────────────┘
```
### 2-A. 내부 DB 선택 시 (기존 UI 유지)
```
┌─────────────────────────────────────┐
│ 테이블 선택: [검색 가능 Combobox] │
│ 필드 매핑: │
│ • source_field → target_field │
│ • ... │
└─────────────────────────────────────┘
```
### 2-B. 외부 DB 선택 시
```
┌─────────────────────────────────────┐
│ 외부 커넥션: [🐘 PostgreSQL - 운영DB]│
│ 스키마: [public ▼] │
│ 테이블: [users ▼] │
│ 필드 매핑: │
│ • source_field → target_field │
└─────────────────────────────────────┘
```
### 2-C. REST API 선택 시
```
┌─────────────────────────────────────┐
│ API 엔드포인트: │
│ [https://api.example.com/users] │
│ │
│ HTTP 메서드: [POST ▼] │
│ │
│ 인증 타입: [Bearer Token ▼] │
│ Token: [••••••••••••••] │
│ │
│ 헤더 (선택): │
│ Content-Type: application/json │
│ + 헤더 추가 │
│ │
│ 바디 템플릿: │
│ { │
│ "name": "{{source.name}}", │
│ "email": "{{source.email}}" │
│ } │
└─────────────────────────────────────┘
```
---
## 🔧 구현 단계
### Phase 1: 타입 정의 및 기본 UI (1-2시간)
- [ ] `types/node-editor.ts``TargetType` 추가
- [ ] `InsertActionNodeData` 등 인터페이스 확장
- [ ] 속성 패널에 타겟 타입 선택 라디오 버튼 추가
### Phase 2: 내부 DB 타입 (기존 유지)
- [ ] `targetType === "internal"` 처리
- [ ] 기존 로직 그대로 유지
- [ ] 기본값으로 설정
### Phase 3: 외부 DB 타입 (2-3시간)
- [ ] 외부 커넥션 선택 UI
- [ ] 외부 테이블/컬럼 로드 (기존 API 재사용)
- [ ] 백엔드: `nodeFlowExecutionService.ts`에 외부 DB 실행 로직 추가
- [ ] `DatabaseConnectorFactory` 활용
### Phase 4: REST API 타입 (3-4시간)
- [ ] API 엔드포인트 설정 UI
- [ ] HTTP 메서드 선택
- [ ] 인증 타입별 설정 UI
- [ ] 바디 템플릿 에디터 (변수 치환 지원)
- [ ] 백엔드: Axios를 사용한 API 호출 로직
- [ ] 응답 처리 및 에러 핸들링
### Phase 5: 노드 시각화 개선 (1시간)
- [ ] 노드에 타겟 타입 아이콘 표시
- 내부 DB: 💾
- 외부 DB: 🔌 + DB 타입 아이콘
- REST API: 🌐
- [ ] 노드 색상 구분
### Phase 6: 검증 및 테스트 (2시간)
- [ ] 타겟 타입별 필수 값 검증
- [ ] 연결 규칙 업데이트
- [ ] 통합 테스트
---
## 🔍 백엔드 실행 로직
### nodeFlowExecutionService.ts
```typescript
private static async executeInsertAction(
node: FlowNode,
inputData: any[],
context: ExecutionContext
): Promise<any[]> {
const { targetType } = node.data;
switch (targetType) {
case "internal":
return this.executeInternalInsert(node, inputData);
case "external":
return this.executeExternalInsert(node, inputData);
case "api":
return this.executeApiInsert(node, inputData);
default:
throw new Error(`지원하지 않는 타겟 타입: ${targetType}`);
}
}
// 🔥 외부 DB INSERT
private static async executeExternalInsert(
node: FlowNode,
inputData: any[]
): Promise<any[]> {
const { externalConnectionId, externalTargetTable, fieldMappings } = node.data;
const connector = await DatabaseConnectorFactory.getConnector(
externalConnectionId!,
node.data.externalDbType!
);
const results = [];
for (const row of inputData) {
const values = fieldMappings.map(m => row[m.sourceField]);
const columns = fieldMappings.map(m => m.targetField);
const result = await connector.executeQuery(
`INSERT INTO ${externalTargetTable} (${columns.join(", ")}) VALUES (${...})`,
values
);
results.push(result);
}
await connector.disconnect();
return results;
}
// 🔥 REST API INSERT (POST)
private static async executeApiInsert(
node: FlowNode,
inputData: any[]
): Promise<any[]> {
const {
apiEndpoint,
apiMethod,
apiAuthType,
apiAuthConfig,
apiHeaders,
apiBodyTemplate
} = node.data;
const axios = require("axios");
const headers = { ...apiHeaders };
// 인증 헤더 추가
if (apiAuthType === "bearer" && apiAuthConfig?.token) {
headers["Authorization"] = `Bearer ${apiAuthConfig.token}`;
} else if (apiAuthType === "apikey" && apiAuthConfig?.apiKey) {
headers[apiAuthConfig.apiKeyHeader || "X-API-Key"] = apiAuthConfig.apiKey;
}
const results = [];
for (const row of inputData) {
// 템플릿 변수 치환
const body = this.replaceTemplateVariables(apiBodyTemplate, row);
const response = await axios({
method: apiMethod || "POST",
url: apiEndpoint,
headers,
data: JSON.parse(body),
});
results.push(response.data);
}
return results;
}
```
---
## 📊 우선순위
### High Priority
1. **Phase 1**: 타입 정의 및 기본 UI
2. **Phase 2**: 내부 DB 타입 (기존 유지)
3. **Phase 3**: 외부 DB 타입
### Medium Priority
4. **Phase 4**: REST API 타입
5. **Phase 5**: 노드 시각화
### Low Priority
6. **Phase 6**: 고급 기능 (재시도, 배치 처리 등)
---
## 🎯 예상 효과
### 1. 유연성 증가
- 다양한 시스템 간 데이터 연동 가능
- 하이브리드 플로우 구성 (내부 → 외부 → API)
### 2. 사용 사례 확장
```
[사례 1] 내부 DB → 외부 DB 동기화
TableSource(내부)
→ DataTransform
→ INSERT(외부 DB)
[사례 2] 내부 DB → REST API 전송
TableSource(내부)
→ DataTransform
→ INSERT(REST API)
[사례 3] 복합 플로우
TableSource(내부)
→ INSERT(외부 DB)
→ INSERT(REST API - 알림)
```
### 3. 기존 기능과의 호환
- 기본값: `targetType = "internal"`
- 기존 플로우 마이그레이션 불필요
---
## ⚠️ 주의사항
### 1. 보안
- API 인증 정보 암호화 저장
- 민감 데이터 로깅 방지
### 2. 에러 핸들링
- 외부 시스템 타임아웃 처리
- 재시도 로직 (선택적)
- 부분 실패 처리 (이미 구현됨)
### 3. 성능
- 외부 DB 연결 풀 관리 (이미 구현됨)
- REST API Rate Limiting 고려

View File

@ -111,13 +111,31 @@ function FlowEditorInner() {
y: event.clientY, y: event.clientY,
}); });
// 🔥 노드 타입별 기본 데이터 설정
const defaultData: any = {
displayName: `${type} 노드`,
};
// 액션 노드의 경우 targetType 기본값 설정
if (["insertAction", "updateAction", "deleteAction", "upsertAction"].includes(type)) {
defaultData.targetType = "internal"; // 기본값: 내부 DB
defaultData.fieldMappings = [];
defaultData.options = {};
if (type === "updateAction" || type === "deleteAction") {
defaultData.whereConditions = [];
}
if (type === "upsertAction") {
defaultData.conflictKeys = [];
}
}
const newNode: any = { const newNode: any = {
id: `node_${Date.now()}`, id: `node_${Date.now()}`,
type, type,
position, position,
data: { data: defaultData,
displayName: `${type} 노드`,
},
}; };
addNode(newNode); addNode(newNode);

View File

@ -5,14 +5,20 @@
*/ */
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Plus, Trash2, AlertTriangle } from "lucide-react"; import { Plus, Trash2, AlertTriangle, Database, Globe, Link2, Check, ChevronsUpDown } from "lucide-react";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
import { tableTypeApi } from "@/lib/api/screen";
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
import type { DeleteActionNodeData } from "@/types/node-editor"; import type { DeleteActionNodeData } from "@/types/node-editor";
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
interface DeleteActionPropertiesProps { interface DeleteActionPropertiesProps {
nodeId: string; nodeId: string;
@ -29,18 +35,157 @@ const OPERATORS = [
] as const; ] as const;
export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesProps) { export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesProps) {
const { updateNode } = useFlowEditorStore(); const { updateNode, getExternalConnectionsCache } = useFlowEditorStore();
// 🔥 타겟 타입 상태
const [targetType, setTargetType] = useState<"internal" | "external" | "api">(data.targetType || "internal");
const [displayName, setDisplayName] = useState(data.displayName || `${data.targetTable} 삭제`); const [displayName, setDisplayName] = useState(data.displayName || `${data.targetTable} 삭제`);
const [targetTable, setTargetTable] = useState(data.targetTable); const [targetTable, setTargetTable] = useState(data.targetTable);
const [whereConditions, setWhereConditions] = useState(data.whereConditions || []); const [whereConditions, setWhereConditions] = useState(data.whereConditions || []);
// 🔥 외부 DB 관련 상태
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
const [externalConnectionsLoading, setExternalConnectionsLoading] = useState(false);
const [selectedExternalConnectionId, setSelectedExternalConnectionId] = useState<number | undefined>(
data.externalConnectionId,
);
const [externalTables, setExternalTables] = useState<ExternalTable[]>([]);
const [externalTablesLoading, setExternalTablesLoading] = useState(false);
const [externalTargetTable, setExternalTargetTable] = useState(data.externalTargetTable);
const [externalColumns, setExternalColumns] = useState<ExternalColumn[]>([]);
const [externalColumnsLoading, setExternalColumnsLoading] = useState(false);
// 🔥 REST API 관련 상태 (DELETE는 요청 바디 없음)
const [apiEndpoint, setApiEndpoint] = useState(data.apiEndpoint || "");
const [apiAuthType, setApiAuthType] = useState<"none" | "basic" | "bearer" | "apikey">(data.apiAuthType || "none");
const [apiAuthConfig, setApiAuthConfig] = useState(data.apiAuthConfig || {});
const [apiHeaders, setApiHeaders] = useState<Record<string, string>>(data.apiHeaders || {});
// 🔥 내부 DB 테이블 관련 상태
const [tables, setTables] = useState<any[]>([]);
const [tablesLoading, setTablesLoading] = useState(false);
const [tablesOpen, setTablesOpen] = useState(false);
const [selectedTableLabel, setSelectedTableLabel] = useState(data.targetTable);
useEffect(() => { useEffect(() => {
setDisplayName(data.displayName || `${data.targetTable} 삭제`); setDisplayName(data.displayName || `${data.targetTable} 삭제`);
setTargetTable(data.targetTable); setTargetTable(data.targetTable);
setWhereConditions(data.whereConditions || []); setWhereConditions(data.whereConditions || []);
}, [data]); }, [data]);
// 🔥 내부 DB 테이블 목록 로딩
useEffect(() => {
if (targetType === "internal") {
loadTables();
}
}, [targetType]);
// 🔥 외부 커넥션 로딩
useEffect(() => {
if (targetType === "external") {
loadExternalConnections();
}
}, [targetType]);
// 🔥 외부 테이블 로딩
useEffect(() => {
if (targetType === "external" && selectedExternalConnectionId) {
loadExternalTables(selectedExternalConnectionId);
}
}, [targetType, selectedExternalConnectionId]);
// 🔥 외부 컬럼 로딩
useEffect(() => {
if (targetType === "external" && selectedExternalConnectionId && externalTargetTable) {
loadExternalColumns(selectedExternalConnectionId, externalTargetTable);
}
}, [targetType, selectedExternalConnectionId, externalTargetTable]);
const loadExternalConnections = async () => {
try {
setExternalConnectionsLoading(true);
const cached = getExternalConnectionsCache();
if (cached) {
setExternalConnections(cached);
return;
}
const data = await getTestedExternalConnections();
setExternalConnections(data);
} catch (error) {
console.error("외부 커넥션 로딩 실패:", error);
} finally {
setExternalConnectionsLoading(false);
}
};
const loadExternalTables = async (connectionId: number) => {
try {
setExternalTablesLoading(true);
const data = await getExternalTables(connectionId);
setExternalTables(data);
} catch (error) {
console.error("외부 테이블 로딩 실패:", error);
} finally {
setExternalTablesLoading(false);
}
};
const loadExternalColumns = async (connectionId: number, tableName: string) => {
try {
setExternalColumnsLoading(true);
const data = await getExternalColumns(connectionId, tableName);
setExternalColumns(data);
} catch (error) {
console.error("외부 컬럼 로딩 실패:", error);
} finally {
setExternalColumnsLoading(false);
}
};
const handleTargetTypeChange = (newType: "internal" | "external" | "api") => {
setTargetType(newType);
updateNode(nodeId, {
targetType: newType,
targetTable: newType === "internal" ? targetTable : undefined,
externalConnectionId: newType === "external" ? selectedExternalConnectionId : undefined,
externalTargetTable: newType === "external" ? externalTargetTable : undefined,
apiEndpoint: newType === "api" ? apiEndpoint : undefined,
apiAuthType: newType === "api" ? apiAuthType : undefined,
apiAuthConfig: newType === "api" ? apiAuthConfig : undefined,
apiHeaders: newType === "api" ? apiHeaders : undefined,
});
};
// 🔥 테이블 목록 로딩
const loadTables = async () => {
try {
setTablesLoading(true);
const tableList = await tableTypeApi.getTables();
setTables(tableList);
} catch (error) {
console.error("테이블 목록 로딩 실패:", error);
} finally {
setTablesLoading(false);
}
};
const handleTableSelect = (tableName: string) => {
const selectedTable = tables.find((t: any) => t.tableName === tableName);
const label = (selectedTable as any)?.tableLabel || selectedTable?.displayName || tableName;
setTargetTable(tableName);
setSelectedTableLabel(label);
setTablesOpen(false);
updateNode(nodeId, {
targetTable: tableName,
displayName: label,
});
};
const handleAddCondition = () => { const handleAddCondition = () => {
setWhereConditions([ setWhereConditions([
...whereConditions, ...whereConditions,
@ -103,17 +248,385 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
/> />
</div> </div>
{/* 🔥 타겟 타입 선택 */}
<div> <div>
<Label htmlFor="targetTable" className="text-xs"> <Label className="mb-2 block text-xs font-medium"> </Label>
<div className="grid grid-cols-3 gap-2">
</Label> <button
type="button"
onClick={() => handleTargetTypeChange("internal")}
className={cn(
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
targetType === "internal" ? "border-blue-500 bg-blue-50" : "border-gray-200 hover:border-gray-300",
)}
>
<Database className={cn("h-5 w-5", targetType === "internal" ? "text-blue-600" : "text-gray-400")} />
<span
className={cn("text-xs font-medium", targetType === "internal" ? "text-blue-700" : "text-gray-600")}
>
DB
</span>
{targetType === "internal" && <Check className="absolute top-2 right-2 h-4 w-4 text-blue-600" />}
</button>
<button
type="button"
onClick={() => handleTargetTypeChange("external")}
className={cn(
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
targetType === "external"
? "border-green-500 bg-green-50"
: "border-gray-200 hover:border-gray-300",
)}
>
<Globe className={cn("h-5 w-5", targetType === "external" ? "text-green-600" : "text-gray-400")} />
<span
className={cn(
"text-xs font-medium",
targetType === "external" ? "text-green-700" : "text-gray-600",
)}
>
DB
</span>
{targetType === "external" && <Check className="absolute top-2 right-2 h-4 w-4 text-green-600" />}
</button>
<button
type="button"
onClick={() => handleTargetTypeChange("api")}
className={cn(
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
targetType === "api" ? "border-purple-500 bg-purple-50" : "border-gray-200 hover:border-gray-300",
)}
>
<Link2 className={cn("h-5 w-5", targetType === "api" ? "text-purple-600" : "text-gray-400")} />
<span
className={cn("text-xs font-medium", targetType === "api" ? "text-purple-700" : "text-gray-600")}
>
REST API
</span>
{targetType === "api" && <Check className="absolute top-2 right-2 h-4 w-4 text-purple-600" />}
</button>
</div>
</div>
{/* 내부 DB: 타겟 테이블 Combobox */}
{targetType === "internal" && (
<div>
<Label className="text-xs"> </Label>
<Popover open={tablesOpen} onOpenChange={setTablesOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tablesOpen}
className="mt-1 w-full justify-between"
disabled={tablesLoading}
>
{tablesLoading ? (
<span className="text-muted-foreground"> ...</span>
) : targetTable ? (
<span className="truncate">{selectedTableLabel}</span>
) : (
<span className="text-muted-foreground"> </span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." />
<CommandEmpty> .</CommandEmpty>
<CommandList>
<CommandGroup>
{tables.map((table: any) => (
<CommandItem
key={table.tableName}
value={`${table.tableLabel || table.displayName} ${table.tableName}`}
onSelect={() => handleTableSelect(table.tableName)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
targetTable === table.tableName ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{table.tableLabel || table.displayName}</span>
<span className="text-muted-foreground text-xs">{table.tableName}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
{/* 🔥 외부 DB 설정 */}
{targetType === "external" && (
<>
<div>
<Label className="mb-1.5 block text-xs font-medium"> </Label>
<Select
value={selectedExternalConnectionId?.toString()}
onValueChange={(value) => {
const connectionId = parseInt(value);
const selectedConnection = externalConnections.find((c) => c.id === connectionId);
setSelectedExternalConnectionId(connectionId);
setExternalTargetTable("");
setExternalColumns([]);
updateNode(nodeId, {
externalConnectionId: connectionId,
externalConnectionName: selectedConnection?.name,
externalDbType: selectedConnection?.db_type,
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="커넥션을 선택하세요" />
</SelectTrigger>
<SelectContent>
{externalConnectionsLoading ? (
<div className="p-2 text-center text-xs text-gray-500"> ...</div>
) : externalConnections.length === 0 ? (
<div className="p-2 text-center text-xs text-gray-500"> </div>
) : (
externalConnections.map((conn) => (
<SelectItem key={conn.id} value={conn.id!.toString()}>
<div className="flex items-center gap-2">
<span className="font-medium">{conn.db_type}</span>
<span className="text-gray-500">-</span>
<span>{conn.name}</span>
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
{selectedExternalConnectionId && (
<div>
<Label className="mb-1.5 block text-xs font-medium"></Label>
<Select
value={externalTargetTable}
onValueChange={(value) => {
const selectedTable = externalTables.find((t) => t.table_name === value);
setExternalTargetTable(value);
updateNode(nodeId, {
externalTargetTable: value,
externalTargetSchema: selectedTable?.schema,
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="테이블을 선택하세요" />
</SelectTrigger>
<SelectContent>
{externalTablesLoading ? (
<div className="p-2 text-center text-xs text-gray-500"> ...</div>
) : externalTables.length === 0 ? (
<div className="p-2 text-center text-xs text-gray-500"> </div>
) : (
externalTables.map((table) => (
<SelectItem key={table.table_name} value={table.table_name}>
<div className="flex items-center gap-2">
<span className="font-medium">{table.table_name}</span>
{table.schema && <span className="text-xs text-gray-500">({table.schema})</span>}
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
)}
{externalTargetTable && externalColumns.length > 0 && (
<div>
<Label className="mb-1.5 block text-xs font-medium"> </Label>
<div className="max-h-32 space-y-1 overflow-y-auto rounded border bg-gray-50 p-2">
{externalColumns.map((col) => (
<div key={col.column_name} className="flex items-center justify-between text-xs">
<span className="font-medium">{col.column_name}</span>
<span className="text-gray-500">{col.data_type}</span>
</div>
))}
</div>
</div>
)}
</>
)}
{/* 🔥 REST API 설정 (DELETE는 간단함) */}
{targetType === "api" && (
<div className="space-y-4">
<div>
<Label className="mb-1.5 block text-xs font-medium">API </Label>
<Input <Input
id="targetTable" placeholder="https://api.example.com/v1/users/{id}"
value={targetTable} value={apiEndpoint}
onChange={(e) => setTargetTable(e.target.value)} onChange={(e) => {
className="mt-1" setApiEndpoint(e.target.value);
updateNode(nodeId, { apiEndpoint: e.target.value });
}}
className="h-8 text-xs"
/> />
</div> </div>
<div>
<Label className="mb-1.5 block text-xs font-medium"> </Label>
<Select
value={apiAuthType}
onValueChange={(value: "none" | "basic" | "bearer" | "apikey") => {
setApiAuthType(value);
updateNode(nodeId, { apiAuthType: value });
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
<SelectItem value="bearer">Bearer Token</SelectItem>
<SelectItem value="basic">Basic Auth</SelectItem>
<SelectItem value="apikey">API Key</SelectItem>
</SelectContent>
</Select>
</div>
{apiAuthType !== "none" && (
<div className="space-y-2 rounded border bg-gray-50 p-3">
<Label className="block text-xs font-medium"> </Label>
{apiAuthType === "bearer" && (
<Input
placeholder="Bearer Token"
value={(apiAuthConfig as any)?.token || ""}
onChange={(e) => {
const newConfig = { token: e.target.value };
setApiAuthConfig(newConfig);
updateNode(nodeId, { apiAuthConfig: newConfig });
}}
className="h-8 text-xs"
/>
)}
{apiAuthType === "basic" && (
<div className="space-y-2">
<Input
placeholder="사용자명"
value={(apiAuthConfig as any)?.username || ""}
onChange={(e) => {
const newConfig = { ...(apiAuthConfig as any), username: e.target.value };
setApiAuthConfig(newConfig);
updateNode(nodeId, { apiAuthConfig: newConfig });
}}
className="h-8 text-xs"
/>
<Input
type="password"
placeholder="비밀번호"
value={(apiAuthConfig as any)?.password || ""}
onChange={(e) => {
const newConfig = { ...(apiAuthConfig as any), password: e.target.value };
setApiAuthConfig(newConfig);
updateNode(nodeId, { apiAuthConfig: newConfig });
}}
className="h-8 text-xs"
/>
</div>
)}
{apiAuthType === "apikey" && (
<div className="space-y-2">
<Input
placeholder="헤더 이름 (예: X-API-Key)"
value={(apiAuthConfig as any)?.headerName || ""}
onChange={(e) => {
const newConfig = { ...(apiAuthConfig as any), headerName: e.target.value };
setApiAuthConfig(newConfig);
updateNode(nodeId, { apiAuthConfig: newConfig });
}}
className="h-8 text-xs"
/>
<Input
placeholder="API Key"
value={(apiAuthConfig as any)?.apiKey || ""}
onChange={(e) => {
const newConfig = { ...(apiAuthConfig as any), apiKey: e.target.value };
setApiAuthConfig(newConfig);
updateNode(nodeId, { apiAuthConfig: newConfig });
}}
className="h-8 text-xs"
/>
</div>
)}
</div>
)}
<div>
<Label className="mb-1.5 block text-xs font-medium"> ()</Label>
<div className="space-y-2 rounded border bg-gray-50 p-3">
{Object.entries(apiHeaders).map(([key, value], index) => (
<div key={index} className="flex gap-2">
<Input
placeholder="헤더 이름"
value={key}
onChange={(e) => {
const newHeaders = { ...apiHeaders };
delete newHeaders[key];
newHeaders[e.target.value] = value;
setApiHeaders(newHeaders);
updateNode(nodeId, { apiHeaders: newHeaders });
}}
className="h-7 flex-1 text-xs"
/>
<Input
placeholder="헤더 값"
value={value}
onChange={(e) => {
const newHeaders = { ...apiHeaders, [key]: e.target.value };
setApiHeaders(newHeaders);
updateNode(nodeId, { apiHeaders: newHeaders });
}}
className="h-7 flex-1 text-xs"
/>
<Button
size="sm"
variant="ghost"
onClick={() => {
const newHeaders = { ...apiHeaders };
delete newHeaders[key];
setApiHeaders(newHeaders);
updateNode(nodeId, { apiHeaders: newHeaders });
}}
className="h-7 w-7 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
<Button
size="sm"
variant="outline"
onClick={() => {
const newHeaders = { ...apiHeaders, "": "" };
setApiHeaders(newHeaders);
updateNode(nodeId, { apiHeaders: newHeaders });
}}
className="h-7 w-full text-xs"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
</div>
)}
</div> </div>
</div> </div>

View File

@ -5,7 +5,7 @@
*/ */
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight } from "lucide-react"; import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2 } from "lucide-react";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -17,7 +17,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
import { tableTypeApi } from "@/lib/api/screen"; import { tableTypeApi } from "@/lib/api/screen";
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
import type { InsertActionNodeData } from "@/types/node-editor"; import type { InsertActionNodeData } from "@/types/node-editor";
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
interface InsertActionPropertiesProps { interface InsertActionPropertiesProps {
nodeId: string; nodeId: string;
@ -39,7 +41,10 @@ interface ColumnInfo {
} }
export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesProps) { export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesProps) {
const { updateNode, nodes, edges } = useFlowEditorStore(); const { updateNode, nodes, edges, getExternalConnectionsCache } = useFlowEditorStore();
// 🔥 타겟 타입 상태
const [targetType, setTargetType] = useState<"internal" | "external" | "api">(data.targetType || "internal");
const [displayName, setDisplayName] = useState(data.displayName || data.targetTable); const [displayName, setDisplayName] = useState(data.displayName || data.targetTable);
const [targetTable, setTargetTable] = useState(data.targetTable); const [targetTable, setTargetTable] = useState(data.targetTable);
@ -48,7 +53,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
const [ignoreErrors, setIgnoreErrors] = useState(data.options?.ignoreErrors || false); const [ignoreErrors, setIgnoreErrors] = useState(data.options?.ignoreErrors || false);
const [ignoreDuplicates, setIgnoreDuplicates] = useState(data.options?.ignoreDuplicates || false); const [ignoreDuplicates, setIgnoreDuplicates] = useState(data.options?.ignoreDuplicates || false);
// 테이블 관련 상태 // 내부 DB 테이블 관련 상태
const [tables, setTables] = useState<TableOption[]>([]); const [tables, setTables] = useState<TableOption[]>([]);
const [tablesLoading, setTablesLoading] = useState(false); const [tablesLoading, setTablesLoading] = useState(false);
const [tablesOpen, setTablesOpen] = useState(false); const [tablesOpen, setTablesOpen] = useState(false);
@ -60,6 +65,26 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
// 소스 필드 목록 (연결된 입력 노드에서 가져오기) // 소스 필드 목록 (연결된 입력 노드에서 가져오기)
const [sourceFields, setSourceFields] = useState<Array<{ name: string; label?: string }>>([]); const [sourceFields, setSourceFields] = useState<Array<{ name: string; label?: string }>>([]);
// 🔥 외부 DB 관련 상태
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
const [externalConnectionsLoading, setExternalConnectionsLoading] = useState(false);
const [selectedExternalConnectionId, setSelectedExternalConnectionId] = useState<number | undefined>(
data.externalConnectionId,
);
const [externalTables, setExternalTables] = useState<ExternalTable[]>([]);
const [externalTablesLoading, setExternalTablesLoading] = useState(false);
const [externalTargetTable, setExternalTargetTable] = useState(data.externalTargetTable);
const [externalColumns, setExternalColumns] = useState<ExternalColumn[]>([]);
const [externalColumnsLoading, setExternalColumnsLoading] = useState(false);
// 🔥 REST API 관련 상태
const [apiEndpoint, setApiEndpoint] = useState(data.apiEndpoint || "");
const [apiMethod, setApiMethod] = useState<"POST" | "PUT" | "PATCH">(data.apiMethod || "POST");
const [apiAuthType, setApiAuthType] = useState<"none" | "basic" | "bearer" | "apikey">(data.apiAuthType || "none");
const [apiAuthConfig, setApiAuthConfig] = useState(data.apiAuthConfig || {});
const [apiHeaders, setApiHeaders] = useState<Record<string, string>>(data.apiHeaders || {});
const [apiBodyTemplate, setApiBodyTemplate] = useState(data.apiBodyTemplate || "");
// 데이터 변경 시 로컬 상태 업데이트 // 데이터 변경 시 로컬 상태 업데이트
useEffect(() => { useEffect(() => {
setDisplayName(data.displayName || data.targetTable); setDisplayName(data.displayName || data.targetTable);
@ -70,17 +95,40 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
setIgnoreDuplicates(data.options?.ignoreDuplicates || false); setIgnoreDuplicates(data.options?.ignoreDuplicates || false);
}, [data]); }, [data]);
// 테이블 목록 로딩 // 내부 DB 테이블 목록 로딩
useEffect(() => { useEffect(() => {
if (targetType === "internal") {
loadTables(); loadTables();
}, []); }
}, [targetType]);
// 타겟 테이블 변경 시 컬럼 로딩 // 타겟 테이블 변경 시 컬럼 로딩 (내부 DB)
useEffect(() => { useEffect(() => {
if (targetTable) { if (targetType === "internal" && targetTable) {
loadColumns(targetTable); loadColumns(targetTable);
} }
}, [targetTable]); }, [targetType, targetTable]);
// 🔥 외부 커넥션 로드 (캐시 우선)
useEffect(() => {
if (targetType === "external") {
loadExternalConnections();
}
}, [targetType]);
// 🔥 외부 커넥션 변경 시 테이블 로드
useEffect(() => {
if (targetType === "external" && selectedExternalConnectionId) {
loadExternalTables(selectedExternalConnectionId);
}
}, [targetType, selectedExternalConnectionId]);
// 🔥 외부 테이블 변경 시 컬럼 로드
useEffect(() => {
if (targetType === "external" && selectedExternalConnectionId && externalTargetTable) {
loadExternalColumns(selectedExternalConnectionId, externalTargetTable);
}
}, [targetType, selectedExternalConnectionId, externalTargetTable]);
// 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색) // 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색)
useEffect(() => { useEffect(() => {
@ -239,6 +287,65 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
} }
}; };
// 🔥 외부 커넥션 로드 (캐시 우선)
const loadExternalConnections = async () => {
try {
// 캐시 확인
const cachedData = getExternalConnectionsCache();
if (cachedData) {
console.log("✅ 캐시된 외부 커넥션 사용:", cachedData.length);
setExternalConnections(cachedData);
return;
}
setExternalConnectionsLoading(true);
console.log("🔍 외부 커넥션 조회 중...");
const connections = await getTestedExternalConnections();
setExternalConnections(connections);
console.log(`✅ 외부 커넥션 ${connections.length}개 로딩 완료`);
} catch (error) {
console.error("❌ 외부 커넥션 로딩 실패:", error);
setExternalConnections([]);
} finally {
setExternalConnectionsLoading(false);
}
};
// 🔥 외부 테이블 로드
const loadExternalTables = async (connectionId: number) => {
try {
setExternalTablesLoading(true);
console.log(`🔍 외부 테이블 조회 중: connection ${connectionId}`);
const tables = await getExternalTables(connectionId);
setExternalTables(tables);
console.log(`✅ 외부 테이블 ${tables.length}개 로딩 완료`);
} catch (error) {
console.error("❌ 외부 테이블 로딩 실패:", error);
setExternalTables([]);
} finally {
setExternalTablesLoading(false);
}
};
// 🔥 외부 컬럼 로드
const loadExternalColumns = async (connectionId: number, tableName: string) => {
try {
setExternalColumnsLoading(true);
console.log(`🔍 외부 컬럼 조회 중: ${tableName}`);
const columns = await getExternalColumns(connectionId, tableName);
setExternalColumns(columns);
console.log(`✅ 외부 컬럼 ${columns.length}개 로딩 완료`);
} catch (error) {
console.error("❌ 외부 컬럼 로딩 실패:", error);
setExternalColumns([]);
} finally {
setExternalColumnsLoading(false);
}
};
/** /**
* *
*/ */
@ -334,9 +441,96 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
const selectedTableLabel = tables.find((t) => t.tableName === targetTable)?.label || targetTable; const selectedTableLabel = tables.find((t) => t.tableName === targetTable)?.label || targetTable;
// 🔥 타겟 타입 변경 핸들러
const handleTargetTypeChange = (newType: "internal" | "external" | "api") => {
setTargetType(newType);
// 타입 변경 시 관련 필드 초기화
const updates: any = {
targetType: newType,
displayName,
};
// 이전 타입의 데이터 유지
if (newType === "internal") {
updates.targetTable = targetTable;
updates.targetTableLabel = data.targetTableLabel;
} else if (newType === "external") {
updates.externalConnectionId = data.externalConnectionId;
updates.externalTargetTable = data.externalTargetTable;
} else if (newType === "api") {
updates.apiEndpoint = data.apiEndpoint;
updates.apiMethod = data.apiMethod || "POST";
}
updates.fieldMappings = fieldMappings;
updates.options = {
batchSize: batchSize ? parseInt(batchSize) : undefined,
ignoreErrors,
ignoreDuplicates,
};
updateNode(nodeId, updates);
};
return ( return (
<ScrollArea className="h-full"> <ScrollArea className="h-full">
<div className="space-y-4 p-4"> <div className="space-y-4 p-4">
{/* 🔥 타겟 타입 선택 */}
<div>
<Label className="mb-2 block text-xs font-medium"> </Label>
<div className="grid grid-cols-3 gap-2">
<button
type="button"
onClick={() => handleTargetTypeChange("internal")}
className={cn(
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
targetType === "internal" ? "border-blue-500 bg-blue-50" : "border-gray-200 hover:border-gray-300",
)}
>
<Database className={cn("h-5 w-5", targetType === "internal" ? "text-blue-600" : "text-gray-400")} />
<span
className={cn("text-xs font-medium", targetType === "internal" ? "text-blue-700" : "text-gray-600")}
>
DB
</span>
{targetType === "internal" && <Check className="absolute top-2 right-2 h-4 w-4 text-blue-600" />}
</button>
<button
type="button"
onClick={() => handleTargetTypeChange("external")}
className={cn(
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
targetType === "external" ? "border-green-500 bg-green-50" : "border-gray-200 hover:border-gray-300",
)}
>
<Globe className={cn("h-5 w-5", targetType === "external" ? "text-green-600" : "text-gray-400")} />
<span
className={cn("text-xs font-medium", targetType === "external" ? "text-green-700" : "text-gray-600")}
>
DB
</span>
{targetType === "external" && <Check className="absolute top-2 right-2 h-4 w-4 text-green-600" />}
</button>
<button
type="button"
onClick={() => handleTargetTypeChange("api")}
className={cn(
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
targetType === "api" ? "border-purple-500 bg-purple-50" : "border-gray-200 hover:border-gray-300",
)}
>
<Link2 className={cn("h-5 w-5", targetType === "api" ? "text-purple-600" : "text-gray-400")} />
<span className={cn("text-xs font-medium", targetType === "api" ? "text-purple-700" : "text-gray-600")}>
REST API
</span>
{targetType === "api" && <Check className="absolute top-2 right-2 h-4 w-4 text-purple-600" />}
</button>
</div>
</div>
{/* 기본 정보 */} {/* 기본 정보 */}
<div> <div>
<h3 className="mb-3 text-sm font-semibold"> </h3> <h3 className="mb-3 text-sm font-semibold"> </h3>
@ -355,6 +549,9 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
/> />
</div> </div>
{/* 🔥 타겟 타입에 따른 조건부 렌더링 */}
{targetType === "internal" && (
<>
{/* 타겟 테이블 Combobox */} {/* 타겟 테이블 Combobox */}
<div> <div>
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
@ -420,10 +617,336 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
</p> </p>
)} )}
</div> </div>
</>
)}
{/* 🔥 외부 DB 타입 UI */}
{targetType === "external" && (
<>
{/* 외부 커넥션 선택 */}
<div>
<Label className="text-xs"> DB </Label>
<Select
value={selectedExternalConnectionId?.toString()}
onValueChange={(value) => {
const connectionId = parseInt(value);
setSelectedExternalConnectionId(connectionId);
setExternalTargetTable(undefined); // 테이블 초기화
const selectedConnection = externalConnections.find((c) => c.id === connectionId);
updateNode(nodeId, {
targetType,
displayName,
externalConnectionId: connectionId,
externalConnectionName: selectedConnection?.connection_name,
externalDbType: selectedConnection?.db_type,
externalTargetTable: undefined,
fieldMappings,
options: {
batchSize: batchSize ? parseInt(batchSize) : undefined,
ignoreErrors,
ignoreDuplicates,
},
});
}}
disabled={externalConnectionsLoading}
>
<SelectTrigger className="mt-1">
<SelectValue placeholder="외부 커넥션을 선택하세요" />
</SelectTrigger>
<SelectContent>
{externalConnections.map((conn) => (
<SelectItem key={conn.id} value={conn.id.toString()}>
<div className="flex items-center gap-2">
<span>{conn.db_type.toUpperCase()}</span>
<span>-</span>
<span>{conn.connection_name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{externalConnectionsLoading && <p className="text-muted-foreground mt-1 text-xs"> ...</p>}
{externalConnections.length === 0 && !externalConnectionsLoading && (
<p className="mt-1 text-xs text-orange-600"> .</p>
)}
</div>
{/* 외부 테이블 선택 */}
{selectedExternalConnectionId && (
<div>
<Label className="text-xs"> </Label>
<Select
value={externalTargetTable}
onValueChange={(value) => {
setExternalTargetTable(value);
updateNode(nodeId, {
targetType,
displayName,
externalConnectionId: selectedExternalConnectionId,
externalTargetTable: value,
fieldMappings,
options: {
batchSize: batchSize ? parseInt(batchSize) : undefined,
ignoreErrors,
ignoreDuplicates,
},
});
}}
disabled={externalTablesLoading}
>
<SelectTrigger className="mt-1">
<SelectValue placeholder="테이블을 선택하세요" />
</SelectTrigger>
<SelectContent>
{externalTables.map((table) => (
<SelectItem key={table.table_name} value={table.table_name}>
{table.table_name}
{table.table_schema && table.table_schema !== "public" && (
<span className="text-muted-foreground ml-2 text-xs">({table.table_schema})</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
{externalTablesLoading && <p className="text-muted-foreground mt-1 text-xs"> ...</p>}
</div>
)}
{/* 외부 컬럼 정보 표시 */}
{selectedExternalConnectionId && externalTargetTable && externalColumns.length > 0 && (
<div className="rounded-lg border bg-gray-50 p-3">
<p className="text-xs font-medium text-gray-700"> ({externalColumns.length})</p>
<div className="mt-2 max-h-[150px] space-y-1 overflow-y-auto">
{externalColumns.map((col) => (
<div key={col.column_name} className="flex items-center justify-between text-xs">
<span className="font-mono text-gray-700">{col.column_name}</span>
<span className="text-gray-500">{col.data_type}</span>
</div>
))}
</div>
</div>
)}
</>
)}
{/* 🔥 REST API 타입 UI (추후 구현) */}
{targetType === "api" && (
<div className="space-y-4">
{/* API 엔드포인트 */}
<div>
<Label className="mb-1.5 block text-xs font-medium">API </Label>
<Input
placeholder="https://api.example.com/v1/users"
value={apiEndpoint}
onChange={(e) => {
setApiEndpoint(e.target.value);
updateNode(nodeId, { apiEndpoint: e.target.value });
}}
className="h-8 text-xs"
/>
</div>
{/* HTTP 메서드 */}
<div>
<Label className="mb-1.5 block text-xs font-medium">HTTP </Label>
<Select
value={apiMethod}
onValueChange={(value: "POST" | "PUT" | "PATCH") => {
setApiMethod(value);
updateNode(nodeId, { apiMethod: value });
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="PATCH">PATCH</SelectItem>
</SelectContent>
</Select>
</div>
{/* 인증 타입 */}
<div>
<Label className="mb-1.5 block text-xs font-medium"> </Label>
<Select
value={apiAuthType}
onValueChange={(value: "none" | "basic" | "bearer" | "apikey") => {
setApiAuthType(value);
updateNode(nodeId, { apiAuthType: value });
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
<SelectItem value="bearer">Bearer Token</SelectItem>
<SelectItem value="basic">Basic Auth</SelectItem>
<SelectItem value="apikey">API Key</SelectItem>
</SelectContent>
</Select>
</div>
{/* 인증 설정 */}
{apiAuthType !== "none" && (
<div className="space-y-2 rounded border bg-gray-50 p-3">
<Label className="block text-xs font-medium"> </Label>
{apiAuthType === "bearer" && (
<Input
placeholder="Bearer Token"
value={(apiAuthConfig as any)?.token || ""}
onChange={(e) => {
const newConfig = { token: e.target.value };
setApiAuthConfig(newConfig);
updateNode(nodeId, { apiAuthConfig: newConfig });
}}
className="h-8 text-xs"
/>
)}
{apiAuthType === "basic" && (
<div className="space-y-2">
<Input
placeholder="사용자명"
value={(apiAuthConfig as any)?.username || ""}
onChange={(e) => {
const newConfig = { ...(apiAuthConfig as any), username: e.target.value };
setApiAuthConfig(newConfig);
updateNode(nodeId, { apiAuthConfig: newConfig });
}}
className="h-8 text-xs"
/>
<Input
type="password"
placeholder="비밀번호"
value={(apiAuthConfig as any)?.password || ""}
onChange={(e) => {
const newConfig = { ...(apiAuthConfig as any), password: e.target.value };
setApiAuthConfig(newConfig);
updateNode(nodeId, { apiAuthConfig: newConfig });
}}
className="h-8 text-xs"
/>
</div>
)}
{apiAuthType === "apikey" && (
<div className="space-y-2">
<Input
placeholder="헤더 이름 (예: X-API-Key)"
value={(apiAuthConfig as any)?.headerName || ""}
onChange={(e) => {
const newConfig = { ...(apiAuthConfig as any), headerName: e.target.value };
setApiAuthConfig(newConfig);
updateNode(nodeId, { apiAuthConfig: newConfig });
}}
className="h-8 text-xs"
/>
<Input
placeholder="API Key"
value={(apiAuthConfig as any)?.apiKey || ""}
onChange={(e) => {
const newConfig = { ...(apiAuthConfig as any), apiKey: e.target.value };
setApiAuthConfig(newConfig);
updateNode(nodeId, { apiAuthConfig: newConfig });
}}
className="h-8 text-xs"
/>
</div>
)}
</div>
)}
{/* 커스텀 헤더 */}
<div>
<Label className="mb-1.5 block text-xs font-medium"> ()</Label>
<div className="space-y-2 rounded border bg-gray-50 p-3">
{Object.entries(apiHeaders).map(([key, value], index) => (
<div key={index} className="flex gap-2">
<Input
placeholder="헤더 이름"
value={key}
onChange={(e) => {
const newHeaders = { ...apiHeaders };
delete newHeaders[key];
newHeaders[e.target.value] = value;
setApiHeaders(newHeaders);
updateNode(nodeId, { apiHeaders: newHeaders });
}}
className="h-7 flex-1 text-xs"
/>
<Input
placeholder="헤더 값"
value={value}
onChange={(e) => {
const newHeaders = { ...apiHeaders, [key]: e.target.value };
setApiHeaders(newHeaders);
updateNode(nodeId, { apiHeaders: newHeaders });
}}
className="h-7 flex-1 text-xs"
/>
<Button
size="sm"
variant="ghost"
onClick={() => {
const newHeaders = { ...apiHeaders };
delete newHeaders[key];
setApiHeaders(newHeaders);
updateNode(nodeId, { apiHeaders: newHeaders });
}}
className="h-7 w-7 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
<Button
size="sm"
variant="outline"
onClick={() => {
const newHeaders = { ...apiHeaders, "": "" };
setApiHeaders(newHeaders);
updateNode(nodeId, { apiHeaders: newHeaders });
}}
className="h-7 w-full text-xs"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div> </div>
</div> </div>
{/* 필드 매핑 */} {/* 요청 바디 설정 */}
<div>
<Label className="mb-1.5 block text-xs font-medium">
릿
<span className="ml-1 text-gray-500">{`{{fieldName}}`} </span>
</Label>
<textarea
placeholder={`{\n "name": "{{name}}",\n "email": "{{email}}",\n "age": "{{age}}"\n}`}
value={apiBodyTemplate}
onChange={(e) => {
setApiBodyTemplate(e.target.value);
updateNode(nodeId, { apiBodyTemplate: e.target.value });
}}
className="w-full rounded border p-2 font-mono text-xs"
rows={8}
/>
<p className="mt-1 text-xs text-gray-500">
{`{{필드명}}`} .
</p>
</div>
</div>
)}
</div>
</div>
{/* 필드 매핑 (REST API 타입에서는 숨김) */}
{targetType !== "api" && (
<div> <div>
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold"> </h3> <h3 className="text-sm font-semibold"> </h3>
@ -433,23 +956,43 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
</Button> </Button>
</div> </div>
{columnsLoading && ( {/* 🔥 로딩 상태 (타입별) */}
{(columnsLoading || externalColumnsLoading) && (
<div className="rounded border p-3 text-center text-xs text-gray-500"> ...</div> <div className="rounded border p-3 text-center text-xs text-gray-500"> ...</div>
)} )}
{!targetTable && !columnsLoading && ( {/* 🔥 테이블 미선택 경고 (타입별) */}
{targetType === "internal" && !targetTable && !columnsLoading && (
<div className="rounded border border-dashed bg-yellow-50 p-3 text-center text-xs text-yellow-700"> <div className="rounded border border-dashed bg-yellow-50 p-3 text-center text-xs text-yellow-700">
</div> </div>
)} )}
{targetTable && !columnsLoading && targetColumns.length === 0 && ( {targetType === "external" && !externalTargetTable && !externalColumnsLoading && (
<div className="rounded border border-dashed bg-yellow-50 p-3 text-center text-xs text-yellow-700">
</div>
)}
{/* 🔥 컬럼 로드 실패 (타입별) */}
{targetType === "internal" && targetTable && !columnsLoading && targetColumns.length === 0 && (
<div className="rounded border border-dashed bg-red-50 p-3 text-center text-xs text-red-700"> <div className="rounded border border-dashed bg-red-50 p-3 text-center text-xs text-red-700">
</div> </div>
)} )}
{targetTable && targetColumns.length > 0 && ( {targetType === "external" &&
externalTargetTable &&
!externalColumnsLoading &&
externalColumns.length === 0 && (
<div className="rounded border border-dashed bg-red-50 p-3 text-center text-xs text-red-700">
DB
</div>
)}
{/* 🔥 필드 매핑 UI (타입별 컬럼 사용) */}
{((targetType === "internal" && targetTable && targetColumns.length > 0) ||
(targetType === "external" && externalTargetTable && externalColumns.length > 0)) && (
<> <>
{fieldMappings.length > 0 ? ( {fieldMappings.length > 0 ? (
<div className="space-y-3"> <div className="space-y-3">
@ -480,7 +1023,9 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{sourceFields.length === 0 ? ( {sourceFields.length === 0 ? (
<div className="p-2 text-center text-xs text-gray-400"> </div> <div className="p-2 text-center text-xs text-gray-400">
</div>
) : ( ) : (
sourceFields.map((field) => ( sourceFields.map((field) => (
<SelectItem key={field.name} value={field.name} className="text-xs"> <SelectItem key={field.name} value={field.name} className="text-xs">
@ -501,7 +1046,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
<ArrowRight className="h-4 w-4 text-green-600" /> <ArrowRight className="h-4 w-4 text-green-600" />
</div> </div>
{/* 타겟 필드 드롭다운 */} {/* 타겟 필드 드롭다운 (🔥 타입별 컬럼 사용) */}
<div> <div>
<Label className="text-xs text-gray-600"> </Label> <Label className="text-xs text-gray-600"> </Label>
<Select <Select
@ -512,7 +1057,9 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
<SelectValue placeholder="타겟 필드 선택" /> <SelectValue placeholder="타겟 필드 선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{targetColumns.map((col) => ( {/* 🔥 내부 DB 컬럼 */}
{targetType === "internal" &&
targetColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName} className="text-xs"> <SelectItem key={col.columnName} value={col.columnName} className="text-xs">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="font-mono">{col.columnLabel || col.columnName}</span> <span className="font-mono">{col.columnLabel || col.columnName}</span>
@ -523,6 +1070,17 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
</div> </div>
</SelectItem> </SelectItem>
))} ))}
{/* 🔥 외부 DB 컬럼 */}
{targetType === "external" &&
externalColumns.map((col) => (
<SelectItem key={col.column_name} value={col.column_name} className="text-xs">
<div className="flex items-center justify-between gap-2">
<span className="font-mono">{col.column_name}</span>
<span className="text-muted-foreground">{col.data_type}</span>
</div>
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@ -550,6 +1108,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
</> </>
)} )}
</div> </div>
)}
{/* 옵션 */} {/* 옵션 */}
<div> <div>

View File

@ -5,7 +5,7 @@
*/ */
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight } from "lucide-react"; import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2 } from "lucide-react";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -17,7 +17,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
import { tableTypeApi } from "@/lib/api/screen"; import { tableTypeApi } from "@/lib/api/screen";
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
import type { UpdateActionNodeData } from "@/types/node-editor"; import type { UpdateActionNodeData } from "@/types/node-editor";
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
interface UpdateActionPropertiesProps { interface UpdateActionPropertiesProps {
nodeId: string; nodeId: string;
@ -54,7 +56,10 @@ const OPERATORS = [
] as const; ] as const;
export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesProps) { export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesProps) {
const { updateNode, nodes, edges } = useFlowEditorStore(); const { updateNode, nodes, edges, getExternalConnectionsCache } = useFlowEditorStore();
// 🔥 타겟 타입 상태
const [targetType, setTargetType] = useState<"internal" | "external" | "api">(data.targetType || "internal");
const [displayName, setDisplayName] = useState(data.displayName || data.targetTable); const [displayName, setDisplayName] = useState(data.displayName || data.targetTable);
const [targetTable, setTargetTable] = useState(data.targetTable); const [targetTable, setTargetTable] = useState(data.targetTable);
@ -63,7 +68,7 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
const [batchSize, setBatchSize] = useState(data.options?.batchSize?.toString() || ""); const [batchSize, setBatchSize] = useState(data.options?.batchSize?.toString() || "");
const [ignoreErrors, setIgnoreErrors] = useState(data.options?.ignoreErrors || false); const [ignoreErrors, setIgnoreErrors] = useState(data.options?.ignoreErrors || false);
// 테이블 관련 상태 // 내부 DB 테이블 관련 상태
const [tables, setTables] = useState<TableOption[]>([]); const [tables, setTables] = useState<TableOption[]>([]);
const [tablesLoading, setTablesLoading] = useState(false); const [tablesLoading, setTablesLoading] = useState(false);
const [tablesOpen, setTablesOpen] = useState(false); const [tablesOpen, setTablesOpen] = useState(false);
@ -75,6 +80,26 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
// 소스 필드 목록 (연결된 입력 노드에서 가져오기) // 소스 필드 목록 (연결된 입력 노드에서 가져오기)
const [sourceFields, setSourceFields] = useState<Array<{ name: string; label?: string }>>([]); const [sourceFields, setSourceFields] = useState<Array<{ name: string; label?: string }>>([]);
// 🔥 외부 DB 관련 상태
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
const [externalConnectionsLoading, setExternalConnectionsLoading] = useState(false);
const [selectedExternalConnectionId, setSelectedExternalConnectionId] = useState<number | undefined>(
data.externalConnectionId,
);
const [externalTables, setExternalTables] = useState<ExternalTable[]>([]);
const [externalTablesLoading, setExternalTablesLoading] = useState(false);
const [externalTargetTable, setExternalTargetTable] = useState(data.externalTargetTable);
const [externalColumns, setExternalColumns] = useState<ExternalColumn[]>([]);
const [externalColumnsLoading, setExternalColumnsLoading] = useState(false);
// 🔥 REST API 관련 상태
const [apiEndpoint, setApiEndpoint] = useState(data.apiEndpoint || "");
const [apiMethod, setApiMethod] = useState<"PUT" | "PATCH">(data.apiMethod || "PUT");
const [apiAuthType, setApiAuthType] = useState<"none" | "basic" | "bearer" | "apikey">(data.apiAuthType || "none");
const [apiAuthConfig, setApiAuthConfig] = useState(data.apiAuthConfig || {});
const [apiHeaders, setApiHeaders] = useState<Record<string, string>>(data.apiHeaders || {});
const [apiBodyTemplate, setApiBodyTemplate] = useState(data.apiBodyTemplate || "");
// 데이터 변경 시 로컬 상태 업데이트 // 데이터 변경 시 로컬 상태 업데이트
useEffect(() => { useEffect(() => {
setDisplayName(data.displayName || data.targetTable); setDisplayName(data.displayName || data.targetTable);
@ -85,17 +110,40 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
setIgnoreErrors(data.options?.ignoreErrors || false); setIgnoreErrors(data.options?.ignoreErrors || false);
}, [data]); }, [data]);
// 테이블 목록 로딩 // 내부 DB 테이블 목록 로딩
useEffect(() => { useEffect(() => {
if (targetType === "internal") {
loadTables(); loadTables();
}, []); }
}, [targetType]);
// 타겟 테이블 변경 시 컬럼 로딩 // 타겟 테이블 변경 시 컬럼 로딩 (내부 DB)
useEffect(() => { useEffect(() => {
if (targetTable) { if (targetType === "internal" && targetTable) {
loadColumns(targetTable); loadColumns(targetTable);
} }
}, [targetTable]); }, [targetType, targetTable]);
// 🔥 외부 DB: 커넥션 목록 로딩
useEffect(() => {
if (targetType === "external") {
loadExternalConnections();
}
}, [targetType]);
// 🔥 외부 DB: 테이블 목록 로딩
useEffect(() => {
if (targetType === "external" && selectedExternalConnectionId) {
loadExternalTables(selectedExternalConnectionId);
}
}, [targetType, selectedExternalConnectionId]);
// 🔥 외부 DB: 컬럼 목록 로딩
useEffect(() => {
if (targetType === "external" && selectedExternalConnectionId && externalTargetTable) {
loadExternalColumns(selectedExternalConnectionId, externalTargetTable);
}
}, [targetType, selectedExternalConnectionId, externalTargetTable]);
// 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색) // 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색)
useEffect(() => { useEffect(() => {
@ -201,6 +249,54 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
} }
}; };
// 🔥 외부 DB 커넥션 목록 로딩
const loadExternalConnections = async () => {
try {
setExternalConnectionsLoading(true);
// 캐시 확인
const cached = getExternalConnectionsCache();
if (cached) {
setExternalConnections(cached);
setExternalConnectionsLoading(false);
return;
}
const connections = await getTestedExternalConnections();
setExternalConnections(connections);
} catch (error) {
console.error("외부 커넥션 목록 로딩 실패:", error);
} finally {
setExternalConnectionsLoading(false);
}
};
// 🔥 외부 DB 테이블 목록 로딩
const loadExternalTables = async (connectionId: number) => {
try {
setExternalTablesLoading(true);
const tables = await getExternalTables(connectionId);
setExternalTables(tables);
} catch (error) {
console.error("외부 테이블 목록 로딩 실패:", error);
} finally {
setExternalTablesLoading(false);
}
};
// 🔥 외부 DB 컬럼 목록 로딩
const loadExternalColumns = async (connectionId: number, tableName: string) => {
try {
setExternalColumnsLoading(true);
const columns = await getExternalColumns(connectionId, tableName);
setExternalColumns(columns);
} catch (error) {
console.error("외부 컬럼 목록 로딩 실패:", error);
} finally {
setExternalColumnsLoading(false);
}
};
const loadColumns = async (tableName: string) => { const loadColumns = async (tableName: string) => {
try { try {
setColumnsLoading(true); setColumnsLoading(true);
@ -302,6 +398,34 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
setFieldMappings(newMappings); setFieldMappings(newMappings);
}; };
// 🔥 타겟 타입 변경 핸들러
const handleTargetTypeChange = (newType: "internal" | "external" | "api") => {
setTargetType(newType);
updateNode(nodeId, {
targetType: newType,
...(newType === "internal" && {
targetTable: data.targetTable,
targetConnection: data.targetConnection,
displayName: data.displayName,
}),
...(newType === "external" && {
externalConnectionId: data.externalConnectionId,
externalConnectionName: data.externalConnectionName,
externalDbType: data.externalDbType,
externalTargetTable: data.externalTargetTable,
externalTargetSchema: data.externalTargetSchema,
}),
...(newType === "api" && {
apiEndpoint: data.apiEndpoint,
apiMethod: data.apiMethod,
apiAuthType: data.apiAuthType,
apiAuthConfig: data.apiAuthConfig,
apiHeaders: data.apiHeaders,
apiBodyTemplate: data.apiBodyTemplate,
}),
});
};
const handleAddCondition = () => { const handleAddCondition = () => {
setWhereConditions([ setWhereConditions([
...whereConditions, ...whereConditions,
@ -391,7 +515,70 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
/> />
</div> </div>
{/* 타겟 테이블 Combobox */} {/* 🔥 타겟 타입 선택 */}
<div>
<Label className="mb-2 block text-xs font-medium"> </Label>
<div className="grid grid-cols-3 gap-2">
{/* 내부 데이터베이스 */}
<button
onClick={() => handleTargetTypeChange("internal")}
className={cn(
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
targetType === "internal" ? "border-blue-500 bg-blue-50" : "border-gray-200 hover:border-gray-300",
)}
>
<Database className={cn("h-5 w-5", targetType === "internal" ? "text-blue-600" : "text-gray-400")} />
<span
className={cn("text-xs font-medium", targetType === "internal" ? "text-blue-700" : "text-gray-600")}
>
DB
</span>
{targetType === "internal" && <Check className="absolute top-2 right-2 h-4 w-4 text-blue-600" />}
</button>
{/* 외부 데이터베이스 */}
<button
onClick={() => handleTargetTypeChange("external")}
className={cn(
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
targetType === "external"
? "border-green-500 bg-green-50"
: "border-gray-200 hover:border-gray-300",
)}
>
<Globe className={cn("h-5 w-5", targetType === "external" ? "text-green-600" : "text-gray-400")} />
<span
className={cn(
"text-xs font-medium",
targetType === "external" ? "text-green-700" : "text-gray-600",
)}
>
DB
</span>
{targetType === "external" && <Check className="absolute top-2 right-2 h-4 w-4 text-green-600" />}
</button>
{/* REST API */}
<button
onClick={() => handleTargetTypeChange("api")}
className={cn(
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
targetType === "api" ? "border-purple-500 bg-purple-50" : "border-gray-200 hover:border-gray-300",
)}
>
<Link2 className={cn("h-5 w-5", targetType === "api" ? "text-purple-600" : "text-gray-400")} />
<span
className={cn("text-xs font-medium", targetType === "api" ? "text-purple-700" : "text-gray-600")}
>
REST API
</span>
{targetType === "api" && <Check className="absolute top-2 right-2 h-4 w-4 text-purple-600" />}
</button>
</div>
</div>
{/* 내부 DB: 타겟 테이블 Combobox */}
{targetType === "internal" && (
<div> <div>
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
<Popover open={tablesOpen} onOpenChange={setTablesOpen}> <Popover open={tablesOpen} onOpenChange={setTablesOpen}>
@ -443,6 +630,318 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
</PopoverContent> </PopoverContent>
</Popover> </Popover>
</div> </div>
)}
{/* 🔥 외부 DB 설정 (INSERT 노드와 동일 패턴) */}
{targetType === "external" && (
<>
{/* 외부 커넥션 선택 */}
<div>
<Label className="mb-1.5 block text-xs font-medium"> </Label>
<Select
value={selectedExternalConnectionId?.toString()}
onValueChange={(value) => {
const connectionId = parseInt(value);
const selectedConnection = externalConnections.find((c) => c.id === connectionId);
setSelectedExternalConnectionId(connectionId);
setExternalTargetTable("");
setExternalColumns([]);
updateNode(nodeId, {
externalConnectionId: connectionId,
externalConnectionName: selectedConnection?.name,
externalDbType: selectedConnection?.db_type,
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="커넥션을 선택하세요" />
</SelectTrigger>
<SelectContent>
{externalConnectionsLoading ? (
<div className="p-2 text-center text-xs text-gray-500"> ...</div>
) : externalConnections.length === 0 ? (
<div className="p-2 text-center text-xs text-gray-500"> </div>
) : (
externalConnections.map((conn) => (
<SelectItem key={conn.id} value={conn.id!.toString()}>
<div className="flex items-center gap-2">
<span className="font-medium">{conn.db_type}</span>
<span className="text-gray-500">-</span>
<span>{conn.name}</span>
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
{/* 외부 테이블 선택 */}
{selectedExternalConnectionId && (
<div>
<Label className="mb-1.5 block text-xs font-medium"></Label>
<Select
value={externalTargetTable}
onValueChange={(value) => {
const selectedTable = externalTables.find((t) => t.table_name === value);
setExternalTargetTable(value);
updateNode(nodeId, {
externalTargetTable: value,
externalTargetSchema: selectedTable?.schema,
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="테이블을 선택하세요" />
</SelectTrigger>
<SelectContent>
{externalTablesLoading ? (
<div className="p-2 text-center text-xs text-gray-500"> ...</div>
) : externalTables.length === 0 ? (
<div className="p-2 text-center text-xs text-gray-500"> </div>
) : (
externalTables.map((table) => (
<SelectItem key={table.table_name} value={table.table_name}>
<div className="flex items-center gap-2">
<span className="font-medium">{table.table_name}</span>
{table.schema && <span className="text-xs text-gray-500">({table.schema})</span>}
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
)}
{/* 외부 컬럼 표시 */}
{externalTargetTable && externalColumns.length > 0 && (
<div>
<Label className="mb-1.5 block text-xs font-medium"> </Label>
<div className="max-h-32 space-y-1 overflow-y-auto rounded border bg-gray-50 p-2">
{externalColumns.map((col) => (
<div key={col.column_name} className="flex items-center justify-between text-xs">
<span className="font-medium">{col.column_name}</span>
<span className="text-gray-500">{col.data_type}</span>
</div>
))}
</div>
</div>
)}
</>
)}
{/* 🔥 REST API 설정 */}
{targetType === "api" && (
<div className="space-y-4">
{/* API 엔드포인트 */}
<div>
<Label className="mb-1.5 block text-xs font-medium">API </Label>
<Input
placeholder="https://api.example.com/v1/users/{id}"
value={apiEndpoint}
onChange={(e) => {
setApiEndpoint(e.target.value);
updateNode(nodeId, { apiEndpoint: e.target.value });
}}
className="h-8 text-xs"
/>
</div>
{/* HTTP 메서드 */}
<div>
<Label className="mb-1.5 block text-xs font-medium">HTTP </Label>
<Select
value={apiMethod}
onValueChange={(value: "PUT" | "PATCH") => {
setApiMethod(value);
updateNode(nodeId, { apiMethod: value });
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="PATCH">PATCH</SelectItem>
</SelectContent>
</Select>
</div>
{/* 인증 타입 */}
<div>
<Label className="mb-1.5 block text-xs font-medium"> </Label>
<Select
value={apiAuthType}
onValueChange={(value: "none" | "basic" | "bearer" | "apikey") => {
setApiAuthType(value);
updateNode(nodeId, { apiAuthType: value });
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
<SelectItem value="bearer">Bearer Token</SelectItem>
<SelectItem value="basic">Basic Auth</SelectItem>
<SelectItem value="apikey">API Key</SelectItem>
</SelectContent>
</Select>
</div>
{/* 인증 설정 */}
{apiAuthType !== "none" && (
<div className="space-y-2 rounded border bg-gray-50 p-3">
<Label className="block text-xs font-medium"> </Label>
{apiAuthType === "bearer" && (
<Input
placeholder="Bearer Token"
value={(apiAuthConfig as any)?.token || ""}
onChange={(e) => {
const newConfig = { token: e.target.value };
setApiAuthConfig(newConfig);
updateNode(nodeId, { apiAuthConfig: newConfig });
}}
className="h-8 text-xs"
/>
)}
{apiAuthType === "basic" && (
<div className="space-y-2">
<Input
placeholder="사용자명"
value={(apiAuthConfig as any)?.username || ""}
onChange={(e) => {
const newConfig = { ...(apiAuthConfig as any), username: e.target.value };
setApiAuthConfig(newConfig);
updateNode(nodeId, { apiAuthConfig: newConfig });
}}
className="h-8 text-xs"
/>
<Input
type="password"
placeholder="비밀번호"
value={(apiAuthConfig as any)?.password || ""}
onChange={(e) => {
const newConfig = { ...(apiAuthConfig as any), password: e.target.value };
setApiAuthConfig(newConfig);
updateNode(nodeId, { apiAuthConfig: newConfig });
}}
className="h-8 text-xs"
/>
</div>
)}
{apiAuthType === "apikey" && (
<div className="space-y-2">
<Input
placeholder="헤더 이름 (예: X-API-Key)"
value={(apiAuthConfig as any)?.headerName || ""}
onChange={(e) => {
const newConfig = { ...(apiAuthConfig as any), headerName: e.target.value };
setApiAuthConfig(newConfig);
updateNode(nodeId, { apiAuthConfig: newConfig });
}}
className="h-8 text-xs"
/>
<Input
placeholder="API Key"
value={(apiAuthConfig as any)?.apiKey || ""}
onChange={(e) => {
const newConfig = { ...(apiAuthConfig as any), apiKey: e.target.value };
setApiAuthConfig(newConfig);
updateNode(nodeId, { apiAuthConfig: newConfig });
}}
className="h-8 text-xs"
/>
</div>
)}
</div>
)}
{/* 커스텀 헤더 */}
<div>
<Label className="mb-1.5 block text-xs font-medium"> ()</Label>
<div className="space-y-2 rounded border bg-gray-50 p-3">
{Object.entries(apiHeaders).map(([key, value], index) => (
<div key={index} className="flex gap-2">
<Input
placeholder="헤더 이름"
value={key}
onChange={(e) => {
const newHeaders = { ...apiHeaders };
delete newHeaders[key];
newHeaders[e.target.value] = value;
setApiHeaders(newHeaders);
updateNode(nodeId, { apiHeaders: newHeaders });
}}
className="h-7 flex-1 text-xs"
/>
<Input
placeholder="헤더 값"
value={value}
onChange={(e) => {
const newHeaders = { ...apiHeaders, [key]: e.target.value };
setApiHeaders(newHeaders);
updateNode(nodeId, { apiHeaders: newHeaders });
}}
className="h-7 flex-1 text-xs"
/>
<Button
size="sm"
variant="ghost"
onClick={() => {
const newHeaders = { ...apiHeaders };
delete newHeaders[key];
setApiHeaders(newHeaders);
updateNode(nodeId, { apiHeaders: newHeaders });
}}
className="h-7 w-7 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
<Button
size="sm"
variant="outline"
onClick={() => {
const newHeaders = { ...apiHeaders, "": "" };
setApiHeaders(newHeaders);
updateNode(nodeId, { apiHeaders: newHeaders });
}}
className="h-7 w-full text-xs"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
{/* 요청 바디 설정 */}
<div>
<Label className="mb-1.5 block text-xs font-medium">
릿
<span className="ml-1 text-gray-500">{`{{fieldName}}`} </span>
</Label>
<textarea
placeholder={`{\n "id": "{{id}}",\n "name": "{{name}}",\n "email": "{{email}}"\n}`}
value={apiBodyTemplate}
onChange={(e) => {
setApiBodyTemplate(e.target.value);
updateNode(nodeId, { apiBodyTemplate: e.target.value });
}}
className="w-full rounded border p-2 font-mono text-xs"
rows={8}
/>
<p className="mt-1 text-xs text-gray-500">
{`{{필드명}}`} .
</p>
</div>
</div>
)}
</div> </div>
</div> </div>
@ -589,7 +1088,8 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
)} )}
</div> </div>
{/* 필드 매핑 */} {/* 필드 매핑 (REST API 타입에서는 숨김) */}
{targetType !== "api" && (
<div> <div>
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold"> </h3> <h3 className="text-sm font-semibold"> </h3>
@ -642,7 +1142,9 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{sourceFields.length === 0 ? ( {sourceFields.length === 0 ? (
<div className="p-2 text-center text-xs text-gray-400"> </div> <div className="p-2 text-center text-xs text-gray-400">
</div>
) : ( ) : (
sourceFields.map((field) => ( sourceFields.map((field) => (
<SelectItem key={field.name} value={field.name} className="text-xs"> <SelectItem key={field.name} value={field.name} className="text-xs">
@ -712,6 +1214,7 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
</> </>
)} )}
</div> </div>
)}
{/* 옵션 */} {/* 옵션 */}
<div> <div>

View File

@ -5,7 +5,7 @@
*/ */
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight } from "lucide-react"; import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2 } from "lucide-react";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -17,7 +17,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
import { tableTypeApi } from "@/lib/api/screen"; import { tableTypeApi } from "@/lib/api/screen";
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
import type { UpsertActionNodeData } from "@/types/node-editor"; import type { UpsertActionNodeData } from "@/types/node-editor";
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
interface UpsertActionPropertiesProps { interface UpsertActionPropertiesProps {
nodeId: string; nodeId: string;
@ -39,7 +41,10 @@ interface ColumnInfo {
} }
export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesProps) { export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesProps) {
const { updateNode, nodes, edges } = useFlowEditorStore(); const { updateNode, nodes, edges, getExternalConnectionsCache } = useFlowEditorStore();
// 🔥 타겟 타입 상태
const [targetType, setTargetType] = useState<"internal" | "external" | "api">(data.targetType || "internal");
const [displayName, setDisplayName] = useState(data.displayName || data.targetTable); const [displayName, setDisplayName] = useState(data.displayName || data.targetTable);
const [targetTable, setTargetTable] = useState(data.targetTable); const [targetTable, setTargetTable] = useState(data.targetTable);
@ -49,6 +54,26 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
const [batchSize, setBatchSize] = useState(data.options?.batchSize?.toString() || ""); const [batchSize, setBatchSize] = useState(data.options?.batchSize?.toString() || "");
const [updateOnConflict, setUpdateOnConflict] = useState(data.options?.updateOnConflict ?? true); const [updateOnConflict, setUpdateOnConflict] = useState(data.options?.updateOnConflict ?? true);
// 🔥 외부 DB 관련 상태
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
const [externalConnectionsLoading, setExternalConnectionsLoading] = useState(false);
const [selectedExternalConnectionId, setSelectedExternalConnectionId] = useState<number | undefined>(
data.externalConnectionId,
);
const [externalTables, setExternalTables] = useState<ExternalTable[]>([]);
const [externalTablesLoading, setExternalTablesLoading] = useState(false);
const [externalTargetTable, setExternalTargetTable] = useState(data.externalTargetTable);
const [externalColumns, setExternalColumns] = useState<ExternalColumn[]>([]);
const [externalColumnsLoading, setExternalColumnsLoading] = useState(false);
// 🔥 REST API 관련 상태
const [apiEndpoint, setApiEndpoint] = useState(data.apiEndpoint || "");
const [apiMethod, setApiMethod] = useState<"POST" | "PUT" | "PATCH">(data.apiMethod || "PUT");
const [apiAuthType, setApiAuthType] = useState<"none" | "basic" | "bearer" | "apikey">(data.apiAuthType || "none");
const [apiAuthConfig, setApiAuthConfig] = useState(data.apiAuthConfig || {});
const [apiHeaders, setApiHeaders] = useState<Record<string, string>>(data.apiHeaders || {});
const [apiBodyTemplate, setApiBodyTemplate] = useState(data.apiBodyTemplate || "");
// 테이블 관련 상태 // 테이블 관련 상태
const [tables, setTables] = useState<TableOption[]>([]); const [tables, setTables] = useState<TableOption[]>([]);
const [tablesLoading, setTablesLoading] = useState(false); const [tablesLoading, setTablesLoading] = useState(false);
@ -72,17 +97,40 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
setUpdateOnConflict(data.options?.updateOnConflict ?? true); setUpdateOnConflict(data.options?.updateOnConflict ?? true);
}, [data]); }, [data]);
// 테이블 목록 로딩 // 🔥 내부 DB 테이블 목록 로딩
useEffect(() => { useEffect(() => {
if (targetType === "internal") {
loadTables(); loadTables();
}, []); }
}, [targetType]);
// 타겟 테이블 변경 시 컬럼 로딩 // 🔥 내부 DB 타겟 테이블 변경 시 컬럼 로딩
useEffect(() => { useEffect(() => {
if (targetTable) { if (targetType === "internal" && targetTable) {
loadColumns(targetTable); loadColumns(targetTable);
} }
}, [targetTable]); }, [targetType, targetTable]);
// 🔥 외부 커넥션 로딩
useEffect(() => {
if (targetType === "external") {
loadExternalConnections();
}
}, [targetType]);
// 🔥 외부 테이블 로딩
useEffect(() => {
if (targetType === "external" && selectedExternalConnectionId) {
loadExternalTables(selectedExternalConnectionId);
}
}, [targetType, selectedExternalConnectionId]);
// 🔥 외부 컬럼 로딩
useEffect(() => {
if (targetType === "external" && selectedExternalConnectionId && externalTargetTable) {
loadExternalColumns(selectedExternalConnectionId, externalTargetTable);
}
}, [targetType, selectedExternalConnectionId, externalTargetTable]);
// 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색) // 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색)
useEffect(() => { useEffect(() => {
@ -162,6 +210,66 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
setSourceFields(uniqueFields); setSourceFields(uniqueFields);
}, [nodeId, nodes, edges]); }, [nodeId, nodes, edges]);
// 🔥 외부 커넥션 로딩 함수
const loadExternalConnections = async () => {
try {
setExternalConnectionsLoading(true);
const cached = getExternalConnectionsCache();
if (cached) {
setExternalConnections(cached);
return;
}
const data = await getTestedExternalConnections();
setExternalConnections(data);
} catch (error) {
console.error("외부 커넥션 로딩 실패:", error);
} finally {
setExternalConnectionsLoading(false);
}
};
const loadExternalTables = async (connectionId: number) => {
try {
setExternalTablesLoading(true);
const data = await getExternalTables(connectionId);
setExternalTables(data);
} catch (error) {
console.error("외부 테이블 로딩 실패:", error);
} finally {
setExternalTablesLoading(false);
}
};
const loadExternalColumns = async (connectionId: number, tableName: string) => {
try {
setExternalColumnsLoading(true);
const data = await getExternalColumns(connectionId, tableName);
setExternalColumns(data);
} catch (error) {
console.error("외부 컬럼 로딩 실패:", error);
} finally {
setExternalColumnsLoading(false);
}
};
const handleTargetTypeChange = (newType: "internal" | "external" | "api") => {
setTargetType(newType);
updateNode(nodeId, {
targetType: newType,
targetTable: newType === "internal" ? targetTable : undefined,
externalConnectionId: newType === "external" ? selectedExternalConnectionId : undefined,
externalTargetTable: newType === "external" ? externalTargetTable : undefined,
apiEndpoint: newType === "api" ? apiEndpoint : undefined,
apiMethod: newType === "api" ? apiMethod : undefined,
apiAuthType: newType === "api" ? apiAuthType : undefined,
apiAuthConfig: newType === "api" ? apiAuthConfig : undefined,
apiHeaders: newType === "api" ? apiHeaders : undefined,
apiBodyTemplate: newType === "api" ? apiBodyTemplate : undefined,
});
};
const loadTables = async () => { const loadTables = async () => {
try { try {
setTablesLoading(true); setTablesLoading(true);
@ -355,7 +463,70 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
/> />
</div> </div>
{/* 타겟 테이블 Combobox */} {/* 🔥 타겟 타입 선택 */}
<div>
<Label className="mb-2 block text-xs font-medium"> </Label>
<div className="grid grid-cols-3 gap-2">
<button
type="button"
onClick={() => handleTargetTypeChange("internal")}
className={cn(
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
targetType === "internal" ? "border-blue-500 bg-blue-50" : "border-gray-200 hover:border-gray-300",
)}
>
<Database className={cn("h-5 w-5", targetType === "internal" ? "text-blue-600" : "text-gray-400")} />
<span
className={cn("text-xs font-medium", targetType === "internal" ? "text-blue-700" : "text-gray-600")}
>
DB
</span>
{targetType === "internal" && <Check className="absolute top-2 right-2 h-4 w-4 text-blue-600" />}
</button>
<button
type="button"
onClick={() => handleTargetTypeChange("external")}
className={cn(
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
targetType === "external"
? "border-green-500 bg-green-50"
: "border-gray-200 hover:border-gray-300",
)}
>
<Globe className={cn("h-5 w-5", targetType === "external" ? "text-green-600" : "text-gray-400")} />
<span
className={cn(
"text-xs font-medium",
targetType === "external" ? "text-green-700" : "text-gray-600",
)}
>
DB
</span>
{targetType === "external" && <Check className="absolute top-2 right-2 h-4 w-4 text-green-600" />}
</button>
<button
type="button"
onClick={() => handleTargetTypeChange("api")}
className={cn(
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
targetType === "api" ? "border-purple-500 bg-purple-50" : "border-gray-200 hover:border-gray-300",
)}
>
<Link2 className={cn("h-5 w-5", targetType === "api" ? "text-purple-600" : "text-gray-400")} />
<span
className={cn("text-xs font-medium", targetType === "api" ? "text-purple-700" : "text-gray-600")}
>
REST API
</span>
{targetType === "api" && <Check className="absolute top-2 right-2 h-4 w-4 text-purple-600" />}
</button>
</div>
</div>
{/* 내부 DB: 타겟 테이블 Combobox */}
{targetType === "internal" && (
<div> <div>
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
<Popover open={tablesOpen} onOpenChange={setTablesOpen}> <Popover open={tablesOpen} onOpenChange={setTablesOpen}>
@ -407,6 +578,310 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
</PopoverContent> </PopoverContent>
</Popover> </Popover>
</div> </div>
)}
{/* 🔥 외부 DB 설정 (INSERT 노드와 동일) */}
{targetType === "external" && (
<>
<div>
<Label className="mb-1.5 block text-xs font-medium"> </Label>
<Select
value={selectedExternalConnectionId?.toString()}
onValueChange={(value) => {
const connectionId = parseInt(value);
const selectedConnection = externalConnections.find((c) => c.id === connectionId);
setSelectedExternalConnectionId(connectionId);
setExternalTargetTable("");
setExternalColumns([]);
updateNode(nodeId, {
externalConnectionId: connectionId,
externalConnectionName: selectedConnection?.name,
externalDbType: selectedConnection?.db_type,
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="커넥션을 선택하세요" />
</SelectTrigger>
<SelectContent>
{externalConnectionsLoading ? (
<div className="p-2 text-center text-xs text-gray-500"> ...</div>
) : externalConnections.length === 0 ? (
<div className="p-2 text-center text-xs text-gray-500"> </div>
) : (
externalConnections.map((conn) => (
<SelectItem key={conn.id} value={conn.id!.toString()}>
<div className="flex items-center gap-2">
<span className="font-medium">{conn.db_type}</span>
<span className="text-gray-500">-</span>
<span>{conn.name}</span>
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
{selectedExternalConnectionId && (
<div>
<Label className="mb-1.5 block text-xs font-medium"></Label>
<Select
value={externalTargetTable}
onValueChange={(value) => {
const selectedTable = externalTables.find((t) => t.table_name === value);
setExternalTargetTable(value);
updateNode(nodeId, {
externalTargetTable: value,
externalTargetSchema: selectedTable?.schema,
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="테이블을 선택하세요" />
</SelectTrigger>
<SelectContent>
{externalTablesLoading ? (
<div className="p-2 text-center text-xs text-gray-500"> ...</div>
) : externalTables.length === 0 ? (
<div className="p-2 text-center text-xs text-gray-500"> </div>
) : (
externalTables.map((table) => (
<SelectItem key={table.table_name} value={table.table_name}>
<div className="flex items-center gap-2">
<span className="font-medium">{table.table_name}</span>
{table.schema && <span className="text-xs text-gray-500">({table.schema})</span>}
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
)}
{externalTargetTable && externalColumns.length > 0 && (
<div>
<Label className="mb-1.5 block text-xs font-medium"> </Label>
<div className="max-h-32 space-y-1 overflow-y-auto rounded border bg-gray-50 p-2">
{externalColumns.map((col) => (
<div key={col.column_name} className="flex items-center justify-between text-xs">
<span className="font-medium">{col.column_name}</span>
<span className="text-gray-500">{col.data_type}</span>
</div>
))}
</div>
</div>
)}
</>
)}
{/* 🔥 REST API 설정 (INSERT 노드와 동일) */}
{targetType === "api" && (
<div className="space-y-4">
<div>
<Label className="mb-1.5 block text-xs font-medium">API </Label>
<Input
placeholder="https://api.example.com/v1/users/{id}"
value={apiEndpoint}
onChange={(e) => {
setApiEndpoint(e.target.value);
updateNode(nodeId, { apiEndpoint: e.target.value });
}}
className="h-8 text-xs"
/>
</div>
<div>
<Label className="mb-1.5 block text-xs font-medium">HTTP </Label>
<Select
value={apiMethod}
onValueChange={(value: "POST" | "PUT" | "PATCH") => {
setApiMethod(value);
updateNode(nodeId, { apiMethod: value });
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="PATCH">PATCH</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1.5 block text-xs font-medium"> </Label>
<Select
value={apiAuthType}
onValueChange={(value: "none" | "basic" | "bearer" | "apikey") => {
setApiAuthType(value);
updateNode(nodeId, { apiAuthType: value });
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
<SelectItem value="bearer">Bearer Token</SelectItem>
<SelectItem value="basic">Basic Auth</SelectItem>
<SelectItem value="apikey">API Key</SelectItem>
</SelectContent>
</Select>
</div>
{apiAuthType !== "none" && (
<div className="space-y-2 rounded border bg-gray-50 p-3">
<Label className="block text-xs font-medium"> </Label>
{apiAuthType === "bearer" && (
<Input
placeholder="Bearer Token"
value={(apiAuthConfig as any)?.token || ""}
onChange={(e) => {
const newConfig = { token: e.target.value };
setApiAuthConfig(newConfig);
updateNode(nodeId, { apiAuthConfig: newConfig });
}}
className="h-8 text-xs"
/>
)}
{apiAuthType === "basic" && (
<div className="space-y-2">
<Input
placeholder="사용자명"
value={(apiAuthConfig as any)?.username || ""}
onChange={(e) => {
const newConfig = { ...(apiAuthConfig as any), username: e.target.value };
setApiAuthConfig(newConfig);
updateNode(nodeId, { apiAuthConfig: newConfig });
}}
className="h-8 text-xs"
/>
<Input
type="password"
placeholder="비밀번호"
value={(apiAuthConfig as any)?.password || ""}
onChange={(e) => {
const newConfig = { ...(apiAuthConfig as any), password: e.target.value };
setApiAuthConfig(newConfig);
updateNode(nodeId, { apiAuthConfig: newConfig });
}}
className="h-8 text-xs"
/>
</div>
)}
{apiAuthType === "apikey" && (
<div className="space-y-2">
<Input
placeholder="헤더 이름 (예: X-API-Key)"
value={(apiAuthConfig as any)?.headerName || ""}
onChange={(e) => {
const newConfig = { ...(apiAuthConfig as any), headerName: e.target.value };
setApiAuthConfig(newConfig);
updateNode(nodeId, { apiAuthConfig: newConfig });
}}
className="h-8 text-xs"
/>
<Input
placeholder="API Key"
value={(apiAuthConfig as any)?.apiKey || ""}
onChange={(e) => {
const newConfig = { ...(apiAuthConfig as any), apiKey: e.target.value };
setApiAuthConfig(newConfig);
updateNode(nodeId, { apiAuthConfig: newConfig });
}}
className="h-8 text-xs"
/>
</div>
)}
</div>
)}
<div>
<Label className="mb-1.5 block text-xs font-medium"> ()</Label>
<div className="space-y-2 rounded border bg-gray-50 p-3">
{Object.entries(apiHeaders).map(([key, value], index) => (
<div key={index} className="flex gap-2">
<Input
placeholder="헤더 이름"
value={key}
onChange={(e) => {
const newHeaders = { ...apiHeaders };
delete newHeaders[key];
newHeaders[e.target.value] = value;
setApiHeaders(newHeaders);
updateNode(nodeId, { apiHeaders: newHeaders });
}}
className="h-7 flex-1 text-xs"
/>
<Input
placeholder="헤더 값"
value={value}
onChange={(e) => {
const newHeaders = { ...apiHeaders, [key]: e.target.value };
setApiHeaders(newHeaders);
updateNode(nodeId, { apiHeaders: newHeaders });
}}
className="h-7 flex-1 text-xs"
/>
<Button
size="sm"
variant="ghost"
onClick={() => {
const newHeaders = { ...apiHeaders };
delete newHeaders[key];
setApiHeaders(newHeaders);
updateNode(nodeId, { apiHeaders: newHeaders });
}}
className="h-7 w-7 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
<Button
size="sm"
variant="outline"
onClick={() => {
const newHeaders = { ...apiHeaders, "": "" };
setApiHeaders(newHeaders);
updateNode(nodeId, { apiHeaders: newHeaders });
}}
className="h-7 w-full text-xs"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
<div>
<Label className="mb-1.5 block text-xs font-medium">
릿
<span className="ml-1 text-gray-500">{`{{fieldName}}`} </span>
</Label>
<textarea
placeholder={`{\n "id": "{{id}}",\n "name": "{{name}}",\n "email": "{{email}}"\n}`}
value={apiBodyTemplate}
onChange={(e) => {
setApiBodyTemplate(e.target.value);
updateNode(nodeId, { apiBodyTemplate: e.target.value });
}}
className="w-full rounded border p-2 font-mono text-xs"
rows={8}
/>
<p className="mt-1 text-xs text-gray-500">
{`{{필드명}}`} .
</p>
</div>
</div>
)}
</div> </div>
</div> </div>

View File

@ -0,0 +1,251 @@
# REST API UI 구현 패턴
UPDATE, DELETE, UPSERT 노드에 적용할 REST API UI 패턴입니다.
## 1. Import 추가
```typescript
import { Database, Globe, Link2 } from "lucide-react";
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
```
## 2. 상태 변수 추가
```typescript
// 타겟 타입 상태
const [targetType, setTargetType] = useState<"internal" | "external" | "api">(data.targetType || "internal");
// 외부 DB 관련 상태
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
const [externalConnectionsLoading, setExternalConnectionsLoading] = useState(false);
const [selectedExternalConnectionId, setSelectedExternalConnectionId] = useState<number | undefined>(data.externalConnectionId);
const [externalTables, setExternalTables] = useState<ExternalTable[]>([]);
const [externalTablesLoading, setExternalTablesLoading] = useState(false);
const [externalTargetTable, setExternalTargetTable] = useState(data.externalTargetTable);
const [externalColumns, setExternalColumns] = useState<ExternalColumn[]>([]);
const [externalColumnsLoading, setExternalColumnsLoading] = useState(false);
// REST API 관련 상태
const [apiEndpoint, setApiEndpoint] = useState(data.apiEndpoint || "");
const [apiMethod, setApiMethod] = useState<"PUT" | "PATCH" | "DELETE">(data.apiMethod || "PUT");
const [apiAuthType, setApiAuthType] = useState<"none" | "basic" | "bearer" | "apikey">(data.apiAuthType || "none");
const [apiAuthConfig, setApiAuthConfig] = useState(data.apiAuthConfig || {});
const [apiHeaders, setApiHeaders] = useState<Record<string, string>>(data.apiHeaders || {});
const [apiBodyTemplate, setApiBodyTemplate] = useState(data.apiBodyTemplate || "");
```
## 3. 타겟 타입 선택 UI (기본 정보 섹션 내부)
기존 "타겟 테이블" 입력 필드 위에 추가:
```tsx
{/* 🔥 타겟 타입 선택 */}
<div>
<Label className="mb-2 block text-xs font-medium">타겟 선택</Label>
<div className="grid grid-cols-3 gap-2">
{/* 내부 데이터베이스 */}
<button
onClick={() => handleTargetTypeChange("internal")}
className={cn(
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
targetType === "internal"
? "border-blue-500 bg-blue-50"
: "border-gray-200 hover:border-gray-300"
)}
>
<Database className={cn("h-5 w-5", targetType === "internal" ? "text-blue-600" : "text-gray-400")} />
<span className={cn("text-xs font-medium", targetType === "internal" ? "text-blue-700" : "text-gray-600")}>
내부 DB
</span>
{targetType === "internal" && (
<Check className="absolute right-2 top-2 h-4 w-4 text-blue-600" />
)}
</button>
{/* 외부 데이터베이스 */}
<button
onClick={() => handleTargetTypeChange("external")}
className={cn(
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
targetType === "external"
? "border-green-500 bg-green-50"
: "border-gray-200 hover:border-gray-300"
)}
>
<Globe className={cn("h-5 w-5", targetType === "external" ? "text-green-600" : "text-gray-400")} />
<span className={cn("text-xs font-medium", targetType === "external" ? "text-green-700" : "text-gray-600")}>
외부 DB
</span>
{targetType === "external" && (
<Check className="absolute right-2 top-2 h-4 w-4 text-green-600" />
)}
</button>
{/* REST API */}
<button
onClick={() => handleTargetTypeChange("api")}
className={cn(
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
targetType === "api"
? "border-purple-500 bg-purple-50"
: "border-gray-200 hover:border-gray-300"
)}
>
<Link2 className={cn("h-5 w-5", targetType === "api" ? "text-purple-600" : "text-gray-400")} />
<span className={cn("text-xs font-medium", targetType === "api" ? "text-purple-700" : "text-gray-600")}>
REST API
</span>
{targetType === "api" && (
<Check className="absolute right-2 top-2 h-4 w-4 text-purple-600" />
)}
</button>
</div>
</div>
```
## 4. REST API 설정 UI (타겟 타입이 "api"일 때)
기존 테이블 선택 UI를 조건부로 변경하고, REST API UI 추가:
```tsx
{/* 내부 DB 설정 */}
{targetType === "internal" && (
<div>
{/* 기존 타겟 테이블 Combobox */}
</div>
)}
{/* 외부 DB 설정 (INSERT 노드 참고) */}
{targetType === "external" && (
<div className="space-y-4">
{/* 외부 커넥션 선택, 테이블 선택, 컬럼 표시 */}
</div>
)}
{/* REST API 설정 */}
{targetType === "api" && (
<div className="space-y-4">
{/* API 엔드포인트 */}
<div>
<Label className="mb-1.5 block text-xs font-medium">API 엔드포인트</Label>
<Input
placeholder="https://api.example.com/v1/users/{id}"
value={apiEndpoint}
onChange={(e) => {
setApiEndpoint(e.target.value);
updateNode(nodeId, { apiEndpoint: e.target.value });
}}
className="h-8 text-xs"
/>
</div>
{/* HTTP 메서드 (UPDATE: PUT/PATCH, DELETE: DELETE만) */}
<div>
<Label className="mb-1.5 block text-xs font-medium">HTTP 메서드</Label>
<Select
value={apiMethod}
onValueChange={(value) => {
setApiMethod(value);
updateNode(nodeId, { apiMethod: value });
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{/* UPDATE 노드: PUT, PATCH */}
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="PATCH">PATCH</SelectItem>
{/* DELETE 노드: DELETE만 */}
{/* <SelectItem value="DELETE">DELETE</SelectItem> */}
</SelectContent>
</Select>
</div>
{/* 인증 방식, 인증 정보, 커스텀 헤더 (INSERT와 동일) */}
{/* 요청 바디 템플릿 (DELETE는 제외) */}
<div>
<Label className="mb-1.5 block text-xs font-medium">
요청 바디 템플릿
<span className="ml-1 text-gray-500">{`{{fieldName}}`}으로 소스 필드 참조</span>
</Label>
<textarea
placeholder={`{\n "id": "{{id}}",\n "name": "{{name}}"\n}`}
value={apiBodyTemplate}
onChange={(e) => {
setApiBodyTemplate(e.target.value);
updateNode(nodeId, { apiBodyTemplate: e.target.value });
}}
className="w-full rounded border p-2 font-mono text-xs"
rows={8}
/>
<p className="mt-1 text-xs text-gray-500">
소스 데이터의 필드명을 {`{{필드명}}`} 형태로 참조할 수 있습니다.
</p>
</div>
</div>
)}
```
## 5. 필드 매핑 섹션 조건부 렌더링
```tsx
{/* 필드 매핑 (REST API 타입에서는 숨김) */}
{targetType !== "api" && (
<div>
{/* 기존 필드 매핑 UI */}
</div>
)}
```
## 6. handleTargetTypeChange 함수
```typescript
const handleTargetTypeChange = (newType: "internal" | "external" | "api") => {
setTargetType(newType);
updateNode(nodeId, {
targetType: newType,
// 타입별로 필요한 데이터만 유지
...(newType === "internal" && {
targetTable: data.targetTable,
targetConnection: data.targetConnection,
displayName: data.displayName,
}),
...(newType === "external" && {
externalConnectionId: data.externalConnectionId,
externalConnectionName: data.externalConnectionName,
externalDbType: data.externalDbType,
externalTargetTable: data.externalTargetTable,
externalTargetSchema: data.externalTargetSchema,
}),
...(newType === "api" && {
apiEndpoint: data.apiEndpoint,
apiMethod: data.apiMethod,
apiAuthType: data.apiAuthType,
apiAuthConfig: data.apiAuthConfig,
apiHeaders: data.apiHeaders,
apiBodyTemplate: data.apiBodyTemplate,
}),
});
};
```
## 노드별 차이점
### UPDATE 노드
- HTTP 메서드: `PUT`, `PATCH`
- WHERE 조건 필요
- 요청 바디 템플릿 필요
### DELETE 노드
- HTTP 메서드: `DELETE`
- WHERE 조건 필요
- 요청 바디 템플릿 **불필요** (쿼리 파라미터로 ID 전달)
### UPSERT 노드
- HTTP 메서드: `POST`, `PUT`, `PATCH`
- Conflict Keys 필요
- 요청 바디 템플릿 필요

View File

@ -22,6 +22,24 @@ export type NodeType =
| "comment" // 주석 | "comment" // 주석
| "log"; // 로그 | "log"; // 로그
// ============================================================================
// 타겟 타입 (액션 노드용)
// ============================================================================
export type TargetType = "internal" | "external" | "api";
// API 인증 타입
export type ApiAuthType = "none" | "basic" | "bearer" | "apikey";
// API 인증 설정
export interface ApiAuthConfig {
username?: string;
password?: string;
token?: string;
apiKey?: string;
apiKeyHeader?: string; // 기본값: "X-API-Key"
}
// ============================================================================ // ============================================================================
// 필드 정의 // 필드 정의
// ============================================================================ // ============================================================================
@ -149,13 +167,37 @@ export interface DataTransformNodeData {
// INSERT 액션 노드 // INSERT 액션 노드
export interface InsertActionNodeData { export interface InsertActionNodeData {
targetConnection: number; displayName?: string;
targetTable: string;
// 🔥 타겟 타입 (새로 추가)
targetType: TargetType; // "internal" | "external" | "api"
// === 내부 DB 타겟 (targetType === "internal") ===
targetConnection?: number;
targetTable?: string;
targetTableLabel?: string;
// === 외부 DB 타겟 (targetType === "external") ===
externalConnectionId?: number;
externalConnectionName?: string;
externalDbType?: string;
externalTargetTable?: string;
externalTargetSchema?: string;
// === REST API 타겟 (targetType === "api") ===
apiEndpoint?: string;
apiMethod?: "POST" | "PUT" | "PATCH" | "DELETE";
apiAuthType?: ApiAuthType;
apiAuthConfig?: ApiAuthConfig;
apiHeaders?: Record<string, string>;
apiBodyTemplate?: string; // JSON 템플릿
// === 공통 필드 ===
fieldMappings: Array<{ fieldMappings: Array<{
sourceField: string | null; sourceField: string | null;
sourceFieldLabel?: string; // 소스 필드 라벨 sourceFieldLabel?: string;
targetField: string; targetField: string;
targetFieldLabel?: string; // 타겟 필드 라벨 targetFieldLabel?: string;
staticValue?: any; staticValue?: any;
}>; }>;
options: { options: {
@ -163,39 +205,85 @@ export interface InsertActionNodeData {
ignoreErrors?: boolean; ignoreErrors?: boolean;
ignoreDuplicates?: boolean; ignoreDuplicates?: boolean;
}; };
displayName?: string;
} }
// UPDATE 액션 노드 // UPDATE 액션 노드
export interface UpdateActionNodeData { export interface UpdateActionNodeData {
targetConnection: number; displayName?: string;
targetTable: string;
// 🔥 타겟 타입 (새로 추가)
targetType: TargetType; // "internal" | "external" | "api"
// === 내부 DB 타겟 (targetType === "internal") ===
targetConnection?: number;
targetTable?: string;
targetTableLabel?: string;
// === 외부 DB 타겟 (targetType === "external") ===
externalConnectionId?: number;
externalConnectionName?: string;
externalDbType?: string;
externalTargetTable?: string;
externalTargetSchema?: string;
// === REST API 타겟 (targetType === "api") ===
apiEndpoint?: string;
apiMethod?: "POST" | "PUT" | "PATCH" | "DELETE";
apiAuthType?: ApiAuthType;
apiAuthConfig?: ApiAuthConfig;
apiHeaders?: Record<string, string>;
apiBodyTemplate?: string; // JSON 템플릿
// === 공통 필드 ===
fieldMappings: Array<{ fieldMappings: Array<{
sourceField: string | null; sourceField: string | null;
sourceFieldLabel?: string; // 소스 필드 라벨 sourceFieldLabel?: string;
targetField: string; targetField: string;
targetFieldLabel?: string; // 타겟 필드 라벨 targetFieldLabel?: string;
staticValue?: any; staticValue?: any;
}>; }>;
whereConditions: Array<{ whereConditions: Array<{
field: string; field: string;
fieldLabel?: string; // 필드 라벨 fieldLabel?: string;
operator: string; operator: string;
sourceField?: string; sourceField?: string;
sourceFieldLabel?: string; // 소스 필드 라벨 sourceFieldLabel?: string;
staticValue?: any; staticValue?: any;
}>; }>;
options: { options: {
batchSize?: number; batchSize?: number;
ignoreErrors?: boolean; ignoreErrors?: boolean;
}; };
displayName?: string;
} }
// DELETE 액션 노드 // DELETE 액션 노드
export interface DeleteActionNodeData { export interface DeleteActionNodeData {
targetConnection: number; displayName?: string;
targetTable: string;
// 🔥 타겟 타입 (새로 추가)
targetType: TargetType; // "internal" | "external" | "api"
// === 내부 DB 타겟 (targetType === "internal") ===
targetConnection?: number;
targetTable?: string;
targetTableLabel?: string;
// === 외부 DB 타겟 (targetType === "external") ===
externalConnectionId?: number;
externalConnectionName?: string;
externalDbType?: string;
externalTargetTable?: string;
externalTargetSchema?: string;
// === REST API 타겟 (targetType === "api") ===
apiEndpoint?: string;
apiMethod?: "POST" | "PUT" | "PATCH" | "DELETE";
apiAuthType?: ApiAuthType;
apiAuthConfig?: ApiAuthConfig;
apiHeaders?: Record<string, string>;
apiBodyTemplate?: string;
// === 공통 필드 ===
whereConditions: Array<{ whereConditions: Array<{
field: string; field: string;
operator: string; operator: string;
@ -205,27 +293,49 @@ export interface DeleteActionNodeData {
options: { options: {
requireConfirmation?: boolean; requireConfirmation?: boolean;
}; };
displayName?: string;
} }
// UPSERT 액션 노드 // UPSERT 액션 노드
export interface UpsertActionNodeData { export interface UpsertActionNodeData {
targetConnection: number; displayName?: string;
targetTable: string;
// 🔥 타겟 타입 (새로 추가)
targetType: TargetType; // "internal" | "external" | "api"
// === 내부 DB 타겟 (targetType === "internal") ===
targetConnection?: number;
targetTable?: string;
targetTableLabel?: string;
// === 외부 DB 타겟 (targetType === "external") ===
externalConnectionId?: number;
externalConnectionName?: string;
externalDbType?: string;
externalTargetTable?: string;
externalTargetSchema?: string;
// === REST API 타겟 (targetType === "api") ===
apiEndpoint?: string;
apiMethod?: "POST" | "PUT" | "PATCH" | "DELETE";
apiAuthType?: ApiAuthType;
apiAuthConfig?: ApiAuthConfig;
apiHeaders?: Record<string, string>;
apiBodyTemplate?: string;
// === 공통 필드 ===
conflictKeys: string[]; // ON CONFLICT 키 conflictKeys: string[]; // ON CONFLICT 키
conflictKeyLabels?: string[]; // 충돌 키 라벨 conflictKeyLabels?: string[]; // 충돌 키 라벨
fieldMappings: Array<{ fieldMappings: Array<{
sourceField: string | null; sourceField: string | null;
sourceFieldLabel?: string; // 소스 필드 라벨 sourceFieldLabel?: string;
targetField: string; targetField: string;
targetFieldLabel?: string; // 타겟 필드 라벨 targetFieldLabel?: string;
staticValue?: any; staticValue?: any;
}>; }>;
options?: { options?: {
batchSize?: number; batchSize?: number;
updateOnConflict?: boolean; updateOnConflict?: boolean;
}; };
displayName?: string;
} }
// REST API 소스 노드 // REST API 소스 노드