diff --git a/backend-node/scripts/add-data-mapping-column.js b/backend-node/scripts/add-data-mapping-column.js new file mode 100644 index 00000000..cd7ee154 --- /dev/null +++ b/backend-node/scripts/add-data-mapping-column.js @@ -0,0 +1,34 @@ +const { PrismaClient } = require("@prisma/client"); + +const prisma = new PrismaClient(); + +async function addDataMappingColumn() { + try { + console.log( + "πŸ”„ external_call_configs ν…Œμ΄λΈ”μ— data_mapping_config 컬럼 μΆ”κ°€ 쀑..." + ); + + // data_mapping_config JSONB 컬럼 μΆ”κ°€ + await prisma.$executeRaw` + ALTER TABLE external_call_configs + ADD COLUMN IF NOT EXISTS data_mapping_config JSONB + `; + + console.log("βœ… data_mapping_config 컬럼이 μ„±κ³΅μ μœΌλ‘œ μΆ”κ°€λ˜μ—ˆμŠ΅λ‹ˆλ‹€."); + + // κΈ°μ‘΄ λ ˆμ½”λ“œμ— κΈ°λ³Έκ°’ μ„€μ • + await prisma.$executeRaw` + UPDATE external_call_configs + SET data_mapping_config = '{"direction": "none"}'::jsonb + WHERE data_mapping_config IS NULL + `; + + console.log("βœ… κΈ°μ‘΄ λ ˆμ½”λ“œμ— 기본값이 μ„€μ •λ˜μ—ˆμŠ΅λ‹ˆλ‹€."); + } catch (error) { + console.error("❌ 컬럼 μΆ”κ°€ μ‹€νŒ¨:", error); + } finally { + await prisma.$disconnect(); + } +} + +addDataMappingColumn(); diff --git a/backend-node/src/services/dataMappingService.ts b/backend-node/src/services/dataMappingService.ts new file mode 100644 index 00000000..af7e9759 --- /dev/null +++ b/backend-node/src/services/dataMappingService.ts @@ -0,0 +1,575 @@ +import { PrismaClient } from "@prisma/client"; +import { + DataMappingConfig, + InboundMapping, + OutboundMapping, + FieldMapping, + DataMappingResult, + MappingValidationResult, + FieldTransform, + DataType, +} from "../types/dataMappingTypes"; + +export class DataMappingService { + private prisma: PrismaClient; + + constructor() { + this.prisma = new PrismaClient(); + } + + /** + * Inbound 데이터 λ§€ν•‘ 처리 (μ™ΈλΆ€ β†’ λ‚΄λΆ€) + */ + async processInboundData( + externalData: any, + mapping: InboundMapping + ): Promise { + const startTime = Date.now(); + const result: DataMappingResult = { + success: false, + direction: "inbound", + recordsProcessed: 0, + recordsInserted: 0, + recordsUpdated: 0, + recordsSkipped: 0, + errors: [], + executionTime: 0, + timestamp: new Date().toISOString(), + }; + + try { + console.log(`πŸ“₯ [DataMappingService] Inbound λ§€ν•‘ μ‹œμž‘:`, { + targetTable: mapping.targetTable, + insertMode: mapping.insertMode, + fieldMappings: mapping.fieldMappings.length, + }); + + // 데이터 λ°°μ—΄λ‘œ λ³€ν™˜ + const dataArray = Array.isArray(externalData) + ? externalData + : [externalData]; + result.recordsProcessed = dataArray.length; + + // 각 λ ˆμ½”λ“œ 처리 + for (const record of dataArray) { + try { + const mappedData = await this.mapInboundRecord(record, mapping); + + if (Object.keys(mappedData).length === 0) { + result.recordsSkipped!++; + continue; + } + + // λ°μ΄ν„°λ² μ΄μŠ€μ— μ €μž₯ + await this.saveInboundRecord(mappedData, mapping); + + if (mapping.insertMode === "insert") { + result.recordsInserted!++; + } else { + result.recordsUpdated!++; + } + } catch (error) { + console.error(`❌ [DataMappingService] λ ˆμ½”λ“œ 처리 μ‹€νŒ¨:`, error); + result.errors!.push( + `λ ˆμ½”λ“œ 처리 μ‹€νŒ¨: ${error instanceof Error ? error.message : String(error)}` + ); + result.recordsSkipped!++; + } + } + + result.success = + result.errors!.length === 0 || + result.recordsInserted! > 0 || + result.recordsUpdated! > 0; + } catch (error) { + console.error(`❌ [DataMappingService] Inbound λ§€ν•‘ μ‹€νŒ¨:`, error); + result.errors!.push( + `λ§€ν•‘ 처리 μ‹€νŒ¨: ${error instanceof Error ? error.message : String(error)}` + ); + } + + result.executionTime = Date.now() - startTime; + + console.log(`βœ… [DataMappingService] Inbound λ§€ν•‘ μ™„λ£Œ:`, result); + return result; + } + + /** + * Outbound 데이터 λ§€ν•‘ 처리 (λ‚΄λΆ€ β†’ μ™ΈλΆ€) + */ + async processOutboundData( + mapping: OutboundMapping, + filter?: any + ): Promise { + console.log(`πŸ“€ [DataMappingService] Outbound λ§€ν•‘ μ‹œμž‘:`, { + sourceTable: mapping.sourceTable, + fieldMappings: mapping.fieldMappings.length, + filter, + }); + + try { + // μ†ŒμŠ€ 데이터 쑰회 + const sourceData = await this.getSourceData(mapping, filter); + + if ( + !sourceData || + (Array.isArray(sourceData) && sourceData.length === 0) + ) { + console.log(`⚠️ [DataMappingService] μ†ŒμŠ€ 데이터가 μ—†μŠ΅λ‹ˆλ‹€.`); + return null; + } + + // 데이터 λ§€ν•‘ + const mappedData = Array.isArray(sourceData) + ? await Promise.all( + sourceData.map((record) => this.mapOutboundRecord(record, mapping)) + ) + : await this.mapOutboundRecord(sourceData, mapping); + + console.log(`βœ… [DataMappingService] Outbound λ§€ν•‘ μ™„λ£Œ:`, { + recordCount: Array.isArray(mappedData) ? mappedData.length : 1, + }); + + return mappedData; + } catch (error) { + console.error(`❌ [DataMappingService] Outbound λ§€ν•‘ μ‹€νŒ¨:`, error); + throw error; + } + } + + /** + * 단일 Inbound λ ˆμ½”λ“œ λ§€ν•‘ + */ + private async mapInboundRecord( + sourceRecord: any, + mapping: InboundMapping + ): Promise> { + const mappedRecord: Record = {}; + + for (const fieldMapping of mapping.fieldMappings) { + try { + const sourceValue = sourceRecord[fieldMapping.sourceField]; + + // ν•„μˆ˜ ν•„λ“œ 체크 + if ( + fieldMapping.required && + (sourceValue === undefined || sourceValue === null) + ) { + if (fieldMapping.defaultValue !== undefined) { + mappedRecord[fieldMapping.targetField] = fieldMapping.defaultValue; + } else { + throw new Error( + `ν•„μˆ˜ ν•„λ“œ '${fieldMapping.sourceField}'κ°€ λˆ„λ½λ˜μ—ˆμŠ΅λ‹ˆλ‹€.` + ); + } + continue; + } + + // 값이 μ—†μœΌλ©΄ κΈ°λ³Έκ°’ μ‚¬μš© + if (sourceValue === undefined || sourceValue === null) { + if (fieldMapping.defaultValue !== undefined) { + mappedRecord[fieldMapping.targetField] = fieldMapping.defaultValue; + } + continue; + } + + // 데이터 λ³€ν™˜ 적용 + const transformedValue = await this.transformFieldValue( + sourceValue, + fieldMapping.dataType, + fieldMapping.transform + ); + + mappedRecord[fieldMapping.targetField] = transformedValue; + } catch (error) { + console.error( + `❌ [DataMappingService] ν•„λ“œ λ§€ν•‘ μ‹€νŒ¨ (${fieldMapping.sourceField} β†’ ${fieldMapping.targetField}):`, + error + ); + throw error; + } + } + + return mappedRecord; + } + + /** + * 단일 Outbound λ ˆμ½”λ“œ λ§€ν•‘ + */ + private async mapOutboundRecord( + sourceRecord: any, + mapping: OutboundMapping + ): Promise> { + const mappedRecord: Record = {}; + + for (const fieldMapping of mapping.fieldMappings) { + try { + const sourceValue = sourceRecord[fieldMapping.sourceField]; + + // 데이터 λ³€ν™˜ 적용 + const transformedValue = await this.transformFieldValue( + sourceValue, + fieldMapping.dataType, + fieldMapping.transform + ); + + mappedRecord[fieldMapping.targetField] = transformedValue; + } catch (error) { + console.error( + `❌ [DataMappingService] ν•„λ“œ λ§€ν•‘ μ‹€νŒ¨ (${fieldMapping.sourceField} β†’ ${fieldMapping.targetField}):`, + error + ); + throw error; + } + } + + return mappedRecord; + } + + /** + * ν•„λ“œ κ°’ λ³€ν™˜ + */ + private async transformFieldValue( + value: any, + targetDataType: DataType, + transform?: FieldTransform + ): Promise { + let transformedValue = value; + + // 1. λ³€ν™˜ ν•¨μˆ˜ 적용 + if (transform) { + switch (transform.type) { + case "constant": + transformedValue = transform.value; + break; + + case "format": + if (targetDataType === "date" && transform.format) { + transformedValue = this.formatDate(value, transform.format); + } + break; + + case "function": + if (transform.functionName) { + transformedValue = await this.applyCustomFunction( + value, + transform.functionName + ); + } + break; + } + } + + // 2. 데이터 νƒ€μž… λ³€ν™˜ + return this.convertDataType(transformedValue, targetDataType); + } + + /** + * 데이터 νƒ€μž… λ³€ν™˜ + */ + private convertDataType(value: any, targetType: DataType): any { + if (value === null || value === undefined) return value; + + switch (targetType) { + case "string": + return String(value); + case "number": + const num = Number(value); + return isNaN(num) ? null : num; + case "boolean": + if (typeof value === "boolean") return value; + if (typeof value === "string") { + return ( + value.toLowerCase() === "true" || value === "1" || value === "Y" + ); + } + return Boolean(value); + case "date": + return new Date(value); + case "json": + return typeof value === "string" ? JSON.parse(value) : value; + default: + return value; + } + } + + /** + * λ‚ μ§œ 포맷 λ³€ν™˜ + */ + private formatDate(value: any, format: string): string { + const date = new Date(value); + if (isNaN(date.getTime())) return value; + + // κ°„λ‹¨ν•œ λ‚ μ§œ 포맷 λ³€ν™˜ + switch (format) { + case "YYYY-MM-DD": + return date.toISOString().split("T")[0]; + case "YYYY-MM-DD HH:mm:ss": + return date + .toISOString() + .replace("T", " ") + .replace(/\.\d{3}Z$/, ""); + default: + return date.toISOString(); + } + } + + /** + * μ»€μŠ€ν…€ ν•¨μˆ˜ 적용 + */ + private async applyCustomFunction( + value: any, + functionName: string + ): Promise { + // μΆ”ν›„ ν™•μž₯ κ°€λŠ₯ν•œ μ»€μŠ€ν…€ ν•¨μˆ˜λ“€ + switch (functionName) { + case "upperCase": + return String(value).toUpperCase(); + case "lowerCase": + return String(value).toLowerCase(); + case "trim": + return String(value).trim(); + default: + console.warn( + `⚠️ [DataMappingService] μ•Œ 수 μ—†λŠ” ν•¨μˆ˜: ${functionName}` + ); + return value; + } + } + + /** + * Inbound 데이터 μ €μž₯ + */ + private async saveInboundRecord( + mappedData: Record, + mapping: InboundMapping + ): Promise { + const tableName = mapping.targetTable; + + try { + switch (mapping.insertMode) { + case "insert": + await this.executeInsert(tableName, mappedData); + break; + + case "upsert": + await this.executeUpsert( + tableName, + mappedData, + mapping.keyFields || [] + ); + break; + + case "update": + await this.executeUpdate( + tableName, + mappedData, + mapping.keyFields || [] + ); + break; + } + } catch (error) { + console.error( + `❌ [DataMappingService] 데이터 μ €μž₯ μ‹€νŒ¨ (${tableName}):`, + error + ); + throw error; + } + } + + /** + * μ†ŒμŠ€ 데이터 쑰회 + */ + private async getSourceData( + mapping: OutboundMapping, + filter?: any + ): Promise { + const tableName = mapping.sourceTable; + + try { + // 동적 ν…Œμ΄λΈ” 쿼리 (Prisma의 경우 λŸ°νƒ€μž„μ—μ„œ μ œν•œμ ) + // μ‹€μ œ κ΅¬ν˜„μ—μ„œλŠ” 각 ν…Œμ΄λΈ”λ³„ λͺ¨λΈμ„ μ‚¬μš©ν•˜κ±°λ‚˜ Raw SQL을 μ‚¬μš©ν•΄μ•Ό 함 + + let whereClause = {}; + if (mapping.sourceFilter) { + // κ°„λ‹¨ν•œ ν•„ν„° νŒŒμ‹± (μ‹€μ œλ‘œλŠ” 더 μ •κ΅ν•œ νŒŒμ‹± ν•„μš”) + console.log( + `πŸ” [DataMappingService] ν•„ν„° 쑰건: ${mapping.sourceFilter}` + ); + // TODO: ν•„ν„° 쑰건 νŒŒμ‹± 및 적용 + } + + if (filter) { + whereClause = { ...whereClause, ...filter }; + } + + // Raw SQL을 μ‚¬μš©ν•œ 동적 쿼리 + const query = `SELECT * FROM ${tableName}${mapping.sourceFilter ? ` WHERE ${mapping.sourceFilter}` : ""}`; + console.log(`πŸ” [DataMappingService] 쿼리 μ‹€ν–‰: ${query}`); + + const result = await this.prisma.$queryRawUnsafe(query); + return result; + } catch (error) { + console.error( + `❌ [DataMappingService] μ†ŒμŠ€ 데이터 쑰회 μ‹€νŒ¨ (${tableName}):`, + error + ); + throw error; + } + } + + /** + * INSERT μ‹€ν–‰ + */ + private async executeInsert( + tableName: string, + data: Record + ): Promise { + const columns = Object.keys(data); + const values = Object.values(data); + const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); + + const query = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders})`; + + console.log(`πŸ“ [DataMappingService] INSERT μ‹€ν–‰:`, { + table: tableName, + columns, + query, + }); + await this.prisma.$executeRawUnsafe(query, ...values); + } + + /** + * UPSERT μ‹€ν–‰ + */ + private async executeUpsert( + tableName: string, + data: Record, + keyFields: string[] + ): Promise { + if (keyFields.length === 0) { + throw new Error("UPSERT λͺ¨λ“œμ—μ„œλŠ” ν‚€ ν•„λ“œκ°€ ν•„μš”ν•©λ‹ˆλ‹€."); + } + + const columns = Object.keys(data); + const values = Object.values(data); + const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); + + const updateClauses = columns + .filter((col) => !keyFields.includes(col)) + .map((col) => `${col} = EXCLUDED.${col}`) + .join(", "); + + const query = ` + INSERT INTO ${tableName} (${columns.join(", ")}) + VALUES (${placeholders}) + ON CONFLICT (${keyFields.join(", ")}) + DO UPDATE SET ${updateClauses} + `; + + console.log(`πŸ”„ [DataMappingService] UPSERT μ‹€ν–‰:`, { + table: tableName, + keyFields, + query, + }); + await this.prisma.$executeRawUnsafe(query, ...values); + } + + /** + * UPDATE μ‹€ν–‰ + */ + private async executeUpdate( + tableName: string, + data: Record, + keyFields: string[] + ): Promise { + if (keyFields.length === 0) { + throw new Error("UPDATE λͺ¨λ“œμ—μ„œλŠ” ν‚€ ν•„λ“œκ°€ ν•„μš”ν•©λ‹ˆλ‹€."); + } + + const updateColumns = Object.keys(data).filter( + (col) => !keyFields.includes(col) + ); + const updateClauses = updateColumns + .map((col, i) => `${col} = $${i + 1}`) + .join(", "); + + const whereConditions = keyFields + .map((field, i) => `${field} = $${updateColumns.length + i + 1}`) + .join(" AND "); + + const values = [ + ...updateColumns.map((col) => data[col]), + ...keyFields.map((field) => data[field]), + ]; + + const query = `UPDATE ${tableName} SET ${updateClauses} WHERE ${whereConditions}`; + + console.log(`✏️ [DataMappingService] UPDATE μ‹€ν–‰:`, { + table: tableName, + keyFields, + query, + }); + await this.prisma.$executeRawUnsafe(query, ...values); + } + + /** + * λ§€ν•‘ μ„€μ • 검증 + */ + validateMappingConfig(config: DataMappingConfig): MappingValidationResult { + const result: MappingValidationResult = { + isValid: true, + errors: [], + warnings: [], + }; + + if (config.direction === "none") { + return result; + } + + // Inbound λ§€ν•‘ 검증 + if ( + (config.direction === "inbound" || + config.direction === "bidirectional") && + config.inboundMapping + ) { + if (!config.inboundMapping.targetTable) { + result.errors.push("Inbound 맀핑에 λŒ€μƒ ν…Œμ΄λΈ”μ΄ ν•„μš”ν•©λ‹ˆλ‹€."); + } + if (config.inboundMapping.fieldMappings.length === 0) { + result.errors.push("Inbound 맀핑에 ν•„λ“œ 맀핑이 ν•„μš”ν•©λ‹ˆλ‹€."); + } + if ( + config.inboundMapping.insertMode !== "insert" && + (!config.inboundMapping.keyFields || + config.inboundMapping.keyFields.length === 0) + ) { + result.errors.push("UPSERT/UPDATE λͺ¨λ“œμ—μ„œλŠ” ν‚€ ν•„λ“œκ°€ ν•„μš”ν•©λ‹ˆλ‹€."); + } + } + + // Outbound λ§€ν•‘ 검증 + if ( + (config.direction === "outbound" || + config.direction === "bidirectional") && + config.outboundMapping + ) { + if (!config.outboundMapping.sourceTable) { + result.errors.push("Outbound 맀핑에 μ†ŒμŠ€ ν…Œμ΄λΈ”μ΄ ν•„μš”ν•©λ‹ˆλ‹€."); + } + if (config.outboundMapping.fieldMappings.length === 0) { + result.errors.push("Outbound 맀핑에 ν•„λ“œ 맀핑이 ν•„μš”ν•©λ‹ˆλ‹€."); + } + } + + result.isValid = result.errors.length === 0; + return result; + } + + /** + * λ¦¬μ†ŒμŠ€ 정리 + */ + async disconnect(): Promise { + await this.prisma.$disconnect(); + } +} diff --git a/backend-node/src/services/externalCallService.ts b/backend-node/src/services/externalCallService.ts index 54c0cbf9..e5cb2dd5 100644 --- a/backend-node/src/services/externalCallService.ts +++ b/backend-node/src/services/externalCallService.ts @@ -10,6 +10,11 @@ import { SupportedExternalCallSettings, TemplateOptions, } from "../types/externalCallTypes"; +import { DataMappingService } from "./dataMappingService"; +import { + DataMappingConfig, + DataMappingResult, +} from "../types/dataMappingTypes"; /** * μ™ΈλΆ€ 호좜 μ„œλΉ„μŠ€ @@ -18,10 +23,149 @@ import { export class ExternalCallService { private readonly DEFAULT_TIMEOUT = 30000; // 30초 private readonly DEFAULT_RETRY_COUNT = 3; + private dataMappingService: DataMappingService; private readonly DEFAULT_RETRY_DELAY = 1000; // 1초 + constructor() { + this.dataMappingService = new DataMappingService(); + } + /** - * μ™ΈλΆ€ 호좜 μ‹€ν–‰ + * 데이터 λ§€ν•‘κ³Ό ν•¨κ»˜ μ™ΈλΆ€ 호좜 μ‹€ν–‰ + */ + async executeWithDataMapping( + config: ExternalCallConfig, + dataMappingConfig?: DataMappingConfig, + triggerData?: any + ): Promise<{ + callResult: ExternalCallResult; + mappingResult?: DataMappingResult; + }> { + const startTime = Date.now(); + + console.log(`πŸš€ [ExternalCallService] 데이터 λ§€ν•‘ 포함 μ™ΈλΆ€ 호좜 μ‹œμž‘:`, { + callType: config.callType, + hasMappingConfig: !!dataMappingConfig, + mappingDirection: dataMappingConfig?.direction, + }); + + try { + let requestData = config; + + // Outbound λ§€ν•‘ 처리 (λ‚΄λΆ€ β†’ μ™ΈλΆ€) + if ( + dataMappingConfig?.direction === "outbound" && + dataMappingConfig.outboundMapping + ) { + console.log(`πŸ“€ [ExternalCallService] Outbound λ§€ν•‘ 처리 μ‹œμž‘`); + + const outboundData = await this.dataMappingService.processOutboundData( + dataMappingConfig.outboundMapping, + triggerData + ); + + // API μš”μ²­ 바디에 λ§€ν•‘λœ 데이터 포함 + if (config.callType === "rest-api") { + // GenericApiSettings둜 νƒ€μž… μΊμŠ€νŒ… + const apiConfig = config as GenericApiSettings; + const bodyTemplate = apiConfig.body || "{}"; + + // ν…œν”Œλ¦Ώμ— 데이터 μ‚½μž… + const processedBody = this.processTemplate(bodyTemplate, { + mappedData: outboundData, + triggerData, + ...outboundData, + }); + + requestData = { + ...config, + body: processedBody, + } as GenericApiSettings; + } + } + + // μ™ΈλΆ€ 호좜 μ‹€ν–‰ + const callRequest: ExternalCallRequest = { + diagramId: 0, // μž„μ‹œκ°’ + relationshipId: "data-mapping", // μž„μ‹œκ°’ + settings: requestData, + templateData: triggerData, + }; + const callResult = await this.executeExternalCall(callRequest); + + let mappingResult: DataMappingResult | undefined; + + // Inbound λ§€ν•‘ 처리 (μ™ΈλΆ€ β†’ λ‚΄λΆ€) + if ( + callResult.success && + dataMappingConfig?.direction === "inbound" && + dataMappingConfig.inboundMapping + ) { + console.log(`πŸ“₯ [ExternalCallService] Inbound λ§€ν•‘ 처리 μ‹œμž‘`); + + try { + // 응닡 데이터 νŒŒμ‹± + let responseData = callResult.response; + if (typeof responseData === "string") { + try { + responseData = JSON.parse(responseData); + } catch { + console.warn( + `⚠️ [ExternalCallService] 응닡 데이터 JSON νŒŒμ‹± μ‹€νŒ¨, λ¬Έμžμ—΄λ‘œ 처리` + ); + } + } + + mappingResult = await this.dataMappingService.processInboundData( + responseData, + dataMappingConfig.inboundMapping + ); + + console.log(`βœ… [ExternalCallService] Inbound λ§€ν•‘ μ™„λ£Œ:`, { + recordsProcessed: mappingResult.recordsProcessed, + recordsInserted: mappingResult.recordsInserted, + }); + } catch (error) { + console.error(`❌ [ExternalCallService] Inbound λ§€ν•‘ μ‹€νŒ¨:`, error); + mappingResult = { + success: false, + direction: "inbound", + errors: [error instanceof Error ? error.message : String(error)], + executionTime: Date.now() - startTime, + timestamp: new Date().toISOString(), + }; + } + } + + // μ–‘λ°©ν–₯ λ§€ν•‘ 처리 + if (dataMappingConfig?.direction === "bidirectional") { + // ν•„μš”ν•œ 경우 μ–‘λ°©ν–₯ λ§€ν•‘ 둜직 κ΅¬ν˜„ + console.log(`πŸ”„ [ExternalCallService] μ–‘λ°©ν–₯ 맀핑은 ν–₯ν›„ κ΅¬ν˜„ μ˜ˆμ •`); + } + + const result = { + callResult, + mappingResult, + }; + + console.log(`βœ… [ExternalCallService] 데이터 λ§€ν•‘ 포함 μ™ΈλΆ€ 호좜 μ™„λ£Œ:`, { + callSuccess: callResult.success, + mappingSuccess: mappingResult?.success, + totalExecutionTime: Date.now() - startTime, + }); + + return result; + } catch (error) { + console.error( + `❌ [ExternalCallService] 데이터 λ§€ν•‘ 포함 μ™ΈλΆ€ 호좜 μ‹€νŒ¨:`, + error + ); + throw error; + } + } + + /** + * κΈ°μ‘΄ μ™ΈλΆ€ 호좜 μ‹€ν–‰ (λ§€ν•‘ μ—†μŒ) */ async executeExternalCall( request: ExternalCallRequest diff --git a/backend-node/src/types/dataMappingTypes.ts b/backend-node/src/types/dataMappingTypes.ts new file mode 100644 index 00000000..8296fe3c --- /dev/null +++ b/backend-node/src/types/dataMappingTypes.ts @@ -0,0 +1,82 @@ +/** + * λ°±μ—”λ“œ 데이터 λ§€ν•‘ κ΄€λ ¨ νƒ€μž… μ •μ˜ + */ + +export type DataDirection = "none" | "inbound" | "outbound" | "bidirectional"; +export type InsertMode = "insert" | "upsert" | "update"; +export type TransformType = "none" | "constant" | "format" | "function"; +export type DataType = "string" | "number" | "boolean" | "date" | "json"; + +export interface FieldTransform { + type: TransformType; + value?: any; + format?: string; + functionName?: string; +} + +export interface FieldMapping { + id: string; + sourceField: string; + targetField: string; + dataType: DataType; + transform?: FieldTransform; + required?: boolean; + defaultValue?: any; +} + +export interface InboundMapping { + targetTable: string; + targetSchema?: string; + fieldMappings: FieldMapping[]; + insertMode: InsertMode; + keyFields?: string[]; + batchSize?: number; +} + +export interface OutboundMapping { + sourceTable: string; + sourceSchema?: string; + sourceFilter?: string; + fieldMappings: FieldMapping[]; + triggerCondition?: string; +} + +export interface DataMappingConfig { + direction: DataDirection; + inboundMapping?: InboundMapping; + outboundMapping?: OutboundMapping; +} + +export interface TableInfo { + name: string; + schema?: string; + displayName?: string; + fields: FieldInfo[]; +} + +export interface FieldInfo { + name: string; + dataType: DataType; + nullable: boolean; + isPrimaryKey?: boolean; + displayName?: string; + description?: string; +} + +export interface DataMappingResult { + success: boolean; + direction: DataDirection; + recordsProcessed?: number; + recordsInserted?: number; + recordsUpdated?: number; + recordsSkipped?: number; + errors?: string[]; + executionTime: number; + timestamp: string; +} + +export interface MappingValidationResult { + isValid: boolean; + errors: string[]; + warnings: string[]; +} diff --git a/frontend/components/dataflow/external-call/DataMappingSettings.tsx b/frontend/components/dataflow/external-call/DataMappingSettings.tsx new file mode 100644 index 00000000..a21a974c --- /dev/null +++ b/frontend/components/dataflow/external-call/DataMappingSettings.tsx @@ -0,0 +1,382 @@ +"use client"; + +import React, { useState, useCallback, useEffect } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { Plus, Trash2, Database, ArrowRight, Settings } from "lucide-react"; + +import { + DataMappingConfig, + DataDirection, + TableInfo, + FieldMapping, + InboundMapping, + OutboundMapping, + DATA_DIRECTION_OPTIONS, + INSERT_MODE_OPTIONS, +} from "@/types/external-call/DataMappingTypes"; + +import { FieldMappingEditor } from "./FieldMappingEditor"; + +interface DataMappingSettingsProps { + config: DataMappingConfig; + onConfigChange: (config: DataMappingConfig) => void; + httpMethod: string; + availableTables?: TableInfo[]; + readonly?: boolean; +} + +export const DataMappingSettings: React.FC = ({ + config, + onConfigChange, + httpMethod, + availableTables = [], + readonly = false, +}) => { + const [localConfig, setLocalConfig] = useState(config); + + // μ»΄ν¬λ„ŒνŠΈ λ³€κ²½ μ‹œ 둜컬 μƒνƒœ 동기화 + useEffect(() => { + setLocalConfig(config); + }, [config]); + + // HTTP λ©”μ„œλ“œμ— λ”°λ₯Έ ꢌμž₯ λ°©ν–₯ κ²°μ • + const getRecommendedDirection = useCallback((method: string): DataDirection => { + const upperMethod = method.toUpperCase(); + if (upperMethod === "GET") return "inbound"; + if (["POST", "PUT", "PATCH"].includes(upperMethod)) return "outbound"; + return "none"; + }, []); + + // λ°©ν–₯ λ³€κ²½ ν•Έλ“€λŸ¬ + const handleDirectionChange = useCallback( + (direction: DataDirection) => { + const newConfig = { + ...localConfig, + direction, + // λ°©ν–₯에 따라 λΆˆν•„μš”ν•œ λ§€ν•‘ 제거 + inboundMapping: + direction === "inbound" || direction === "bidirectional" + ? localConfig.inboundMapping || { + targetTable: "", + fieldMappings: [], + insertMode: "insert" as const, + } + : undefined, + outboundMapping: + direction === "outbound" || direction === "bidirectional" + ? localConfig.outboundMapping || { + sourceTable: "", + fieldMappings: [], + } + : undefined, + }; + setLocalConfig(newConfig); + onConfigChange(newConfig); + }, + [localConfig, onConfigChange], + ); + + // Inbound λ§€ν•‘ μ—…λ°μ΄νŠΈ + const handleInboundMappingChange = useCallback( + (mapping: Partial) => { + const newConfig = { + ...localConfig, + inboundMapping: { + ...localConfig.inboundMapping!, + ...mapping, + }, + }; + setLocalConfig(newConfig); + onConfigChange(newConfig); + }, + [localConfig, onConfigChange], + ); + + // Outbound λ§€ν•‘ μ—…λ°μ΄νŠΈ + const handleOutboundMappingChange = useCallback( + (mapping: Partial) => { + const newConfig = { + ...localConfig, + outboundMapping: { + ...localConfig.outboundMapping!, + ...mapping, + }, + }; + setLocalConfig(newConfig); + onConfigChange(newConfig); + }, + [localConfig, onConfigChange], + ); + + // ν•„λ“œ λ§€ν•‘ μ—…λ°μ΄νŠΈ (Inbound) + const handleInboundFieldMappingsChange = useCallback( + (fieldMappings: FieldMapping[]) => { + handleInboundMappingChange({ fieldMappings }); + }, + [handleInboundMappingChange], + ); + + // ν•„λ“œ λ§€ν•‘ μ—…λ°μ΄νŠΈ (Outbound) + const handleOutboundFieldMappingsChange = useCallback( + (fieldMappings: FieldMapping[]) => { + handleOutboundMappingChange({ fieldMappings }); + }, + [handleOutboundMappingChange], + ); + + // 검증 ν•¨μˆ˜ + const isConfigValid = useCallback(() => { + if (localConfig.direction === "none") return true; + + if ( + (localConfig.direction === "inbound" || localConfig.direction === "bidirectional") && + localConfig.inboundMapping + ) { + if (!localConfig.inboundMapping.targetTable) return false; + if (localConfig.inboundMapping.fieldMappings.length === 0) return false; + } + + if ( + (localConfig.direction === "outbound" || localConfig.direction === "bidirectional") && + localConfig.outboundMapping + ) { + if (!localConfig.outboundMapping.sourceTable) return false; + if (localConfig.outboundMapping.fieldMappings.length === 0) return false; + } + + return true; + }, [localConfig]); + + const recommendedDirection = getRecommendedDirection(httpMethod); + + return ( + + + + + 데이터 λ§€ν•‘ μ„€μ • + {!isConfigValid() && μ„€μ • ν•„μš”} + {isConfigValid() && localConfig.direction !== "none" && μ„€μ • μ™„λ£Œ} + +

μ™ΈλΆ€ API와 λ‚΄λΆ€ ν…Œμ΄λΈ” κ°„μ˜ 데이터 맀핑을 μ„€μ •ν•©λ‹ˆλ‹€.

+
+ + {/* λ§€ν•‘ λ°©ν–₯ 선택 */} +
+ + + {localConfig.direction !== recommendedDirection && recommendedDirection !== "none" && ( +

+ πŸ’‘ {httpMethod} μš”μ²­μ—λŠ” "{DATA_DIRECTION_OPTIONS.find((o) => o.value === recommendedDirection)?.label}" + λ°©ν–₯이 ꢌμž₯λ©λ‹ˆλ‹€. +

+ )} +
+ + {/* λ§€ν•‘ μ„€μ • νƒ­ */} + {localConfig.direction !== "none" && ( + + + {(localConfig.direction === "inbound" || localConfig.direction === "bidirectional") && ( + + + μ™ΈλΆ€ β†’ λ‚΄λΆ€ + + )} + {(localConfig.direction === "outbound" || localConfig.direction === "bidirectional") && ( + + + λ‚΄λΆ€ β†’ μ™ΈλΆ€ + + )} + + + {/* Inbound λ§€ν•‘ μ„€μ • */} + {(localConfig.direction === "inbound" || localConfig.direction === "bidirectional") && ( + +
+
+ + +
+ +
+ + +
+
+ + {/* ν‚€ ν•„λ“œ μ„€μ • (upsert/update λͺ¨λ“œμΌ λ•Œ) */} + {localConfig.inboundMapping?.insertMode !== "insert" && ( +
+ + + handleInboundMappingChange({ + keyFields: e.target.value + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + }) + } + placeholder="id, code" + disabled={readonly} + /> +

+ μ—…λ°μ΄νŠΈ/μ—…μ„œνŠΈ μ‹œ μ‚¬μš©ν•  ν‚€ ν•„λ“œλ₯Ό μ‰Όν‘œλ‘œ κ΅¬λΆ„ν•˜μ—¬ μž…λ ₯ν•˜μ„Έμš”. +

+
+ )} + + {/* ν•„λ“œ λ§€ν•‘ 에디터 */} + {localConfig.inboundMapping?.targetTable && ( + t.name === localConfig.inboundMapping?.targetTable)} + readonly={readonly} + /> + )} +
+ )} + + {/* Outbound λ§€ν•‘ μ„€μ • */} + {(localConfig.direction === "outbound" || localConfig.direction === "bidirectional") && ( + +
+
+ + +
+
+ + {/* μ†ŒμŠ€ ν•„ν„° 쑰건 */} +
+ +