diff --git a/UI_REDESIGN_PLAN.md b/UI_REDESIGN_PLAN.md new file mode 100644 index 00000000..930a693c --- /dev/null +++ b/UI_REDESIGN_PLAN.md @@ -0,0 +1,311 @@ +# 🎨 제어관리 - 데이터 연결 설정 UI 재설계 계획서 + +## 📋 프로젝트 개요 + +### 목표 + +- 기존 모달 기반 필드 매핑을 메인 화면으로 통합 +- 중복된 테이블 선택 과정 제거 +- 시각적 필드 연결 매핑 구현 +- 좌우 분할 레이아웃으로 정보 가시성 향상 + +### 현재 문제점 + +- ❌ **이중 작업**: 테이블을 3번 선택해야 함 (더블클릭 → 모달 → 재선택) +- ❌ **혼란스러운 UX**: 사전 선택의 의미가 없어짐 +- ❌ **불필요한 모달**: 연결 설정이 메인 기능인데 숨겨져 있음 +- ❌ **시각적 피드백 부족**: 필드 매핑 관계가 명확하지 않음 + +## 🎯 새로운 UI 구조 + +### 레이아웃 구성 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 제어관리 - 데이터 연결 설정 │ +├─────────────────────────────────────────────────────────────┤ +│ 좌측 패널 (30%) │ 우측 패널 (70%) │ +│ - 연결 타입 선택 │ - 단계별 설정 UI │ +│ - 매핑 정보 모니터링 │ - 시각적 필드 매핑 │ +│ - 상세 설정 목록 │ - 실시간 연결선 표시 │ +│ - 액션 버튼 │ - 드래그 앤 드롭 지원 │ +└─────────────────────────────────────────────────────────────┘ +``` + +## 🔧 구현 단계 + +### Phase 1: 기본 구조 구축 + +- [ ] 좌우 분할 레이아웃 컴포넌트 생성 +- [ ] 기존 모달 컴포넌트들을 메인 화면용으로 리팩토링 +- [ ] 연결 타입 선택 컴포넌트 구현 + +### Phase 2: 좌측 패널 구현 + +- [ ] 연결 타입 선택 (데이터 저장 / 외부 호출) +- [ ] 실시간 매핑 정보 표시 +- [ ] 매핑 상세 목록 컴포넌트 +- [ ] 고급 설정 패널 + +### Phase 3: 우측 패널 구현 + +- [ ] 단계별 진행 UI (연결 → 테이블 → 매핑) +- [ ] 시각적 필드 매핑 영역 +- [ ] SVG 기반 연결선 시스템 +- [ ] 드래그 앤 드롭 매핑 기능 + +### Phase 4: 고급 기능 + +- [ ] 실시간 검증 및 피드백 +- [ ] 매핑 미리보기 기능 +- [ ] 설정 저장/불러오기 +- [ ] 테스트 실행 기능 + +## 📁 파일 구조 + +### 새로 생성할 컴포넌트 + +``` +frontend/components/dataflow/connection/redesigned/ +├── DataConnectionDesigner.tsx # 메인 컨테이너 +├── LeftPanel/ +│ ├── ConnectionTypeSelector.tsx # 연결 타입 선택 +│ ├── MappingInfoPanel.tsx # 매핑 정보 표시 +│ ├── MappingDetailList.tsx # 매핑 상세 목록 +│ ├── AdvancedSettings.tsx # 고급 설정 +│ └── ActionButtons.tsx # 액션 버튼들 +├── RightPanel/ +│ ├── StepProgress.tsx # 단계 진행 표시 +│ ├── ConnectionStep.tsx # 1단계: 연결 선택 +│ ├── TableStep.tsx # 2단계: 테이블 선택 +│ ├── FieldMappingStep.tsx # 3단계: 필드 매핑 +│ └── VisualMapping/ +│ ├── FieldMappingCanvas.tsx # 시각적 매핑 캔버스 +│ ├── FieldColumn.tsx # 필드 컬럼 컴포넌트 +│ ├── ConnectionLine.tsx # SVG 연결선 +│ └── MappingControls.tsx # 매핑 제어 도구 +└── types/ + └── redesigned.ts # 타입 정의 +``` + +### 수정할 기존 파일 + +``` +frontend/components/dataflow/connection/ +├── DataSaveSettings.tsx # 새 UI로 교체 +├── ConnectionSelectionPanel.tsx # 재사용을 위한 리팩토링 +├── TableSelectionPanel.tsx # 재사용을 위한 리팩토링 +└── ActionFieldMappings.tsx # 레거시 처리 +``` + +## 🎨 UI 컴포넌트 상세 + +### 1. 연결 타입 선택 (ConnectionTypeSelector) + +```typescript +interface ConnectionType { + id: "data_save" | "external_call"; + label: string; + description: string; + icon: React.ReactNode; +} + +const connectionTypes: ConnectionType[] = [ + { + id: "data_save", + label: "데이터 저장", + description: "INSERT/UPDATE/DELETE 작업", + icon: , + }, + { + id: "external_call", + label: "외부 호출", + description: "API/Webhook 호출", + icon: , + }, +]; +``` + +### 2. 시각적 필드 매핑 (FieldMappingCanvas) + +```typescript +interface FieldMapping { + id: string; + fromField: ColumnInfo; + toField: ColumnInfo; + transformRule?: string; + isValid: boolean; + validationMessage?: string; +} + +interface MappingLine { + id: string; + fromX: number; + fromY: number; + toX: number; + toY: number; + isValid: boolean; + isHovered: boolean; +} +``` + +### 3. 매핑 정보 패널 (MappingInfoPanel) + +```typescript +interface MappingStats { + totalMappings: number; + validMappings: number; + invalidMappings: number; + missingRequiredFields: number; + estimatedRows: number; + actionType: "INSERT" | "UPDATE" | "DELETE"; +} +``` + +## 🔄 데이터 플로우 + +### 상태 관리 + +```typescript +interface DataConnectionState { + // 기본 설정 + connectionType: "data_save" | "external_call"; + currentStep: 1 | 2 | 3; + + // 연결 정보 + fromConnection?: Connection; + toConnection?: Connection; + fromTable?: TableInfo; + toTable?: TableInfo; + + // 매핑 정보 + fieldMappings: FieldMapping[]; + mappingStats: MappingStats; + + // UI 상태 + selectedMapping?: string; + isLoading: boolean; + validationErrors: ValidationError[]; +} +``` + +### 이벤트 핸들링 + +```typescript +interface DataConnectionActions { + // 연결 타입 + setConnectionType: (type: "data_save" | "external_call") => void; + + // 단계 진행 + goToStep: (step: 1 | 2 | 3) => void; + + // 연결/테이블 선택 + selectConnection: (type: "from" | "to", connection: Connection) => void; + selectTable: (type: "from" | "to", table: TableInfo) => void; + + // 필드 매핑 + createMapping: (fromField: ColumnInfo, toField: ColumnInfo) => void; + updateMapping: (mappingId: string, updates: Partial) => void; + deleteMapping: (mappingId: string) => void; + + // 검증 및 저장 + validateMappings: () => Promise; + saveMappings: () => Promise; + testExecution: () => Promise; +} +``` + +## 🎯 사용자 경험 (UX) 개선점 + +### Before (기존) + +1. 테이블 더블클릭 → 화면에 표시 +2. 모달 열기 → 다시 테이블 선택 +3. 외부 커넥션 설정 → 또 다시 테이블 선택 +4. 필드 매핑 → 텍스트 기반 매핑 + +### After (개선) + +1. **연결 타입 선택** → 목적 명확화 +2. **연결 선택** → 한 번에 FROM/TO 설정 +3. **테이블 선택** → 즉시 필드 정보 로드 +4. **시각적 매핑** → 드래그 앤 드롭으로 직관적 연결 + +## 🚀 구현 우선순위 + +### 🔥 High Priority + +1. **기본 레이아웃** - 좌우 분할 구조 +2. **연결 타입 선택** - 데이터 저장/외부 호출 +3. **단계별 진행** - 연결 → 테이블 → 매핑 +4. **기본 필드 매핑** - 드래그 앤 드롭 없이 클릭 기반 + +### 🔶 Medium Priority + +1. **시각적 연결선** - SVG 기반 라인 표시 +2. **실시간 검증** - 타입 호환성 체크 +3. **매핑 정보 패널** - 통계 및 상태 표시 +4. **드래그 앤 드롭** - 고급 매핑 기능 + +### 🔵 Low Priority + +1. **고급 설정** - 트랜잭션, 배치 설정 +2. **미리보기 기능** - 데이터 변환 미리보기 +3. **설정 템플릿** - 자주 사용하는 매핑 저장 +4. **성능 최적화** - 대용량 테이블 처리 + +## 📅 개발 일정 + +### Week 1: 기본 구조 + +- [ ] 레이아웃 컴포넌트 생성 +- [ ] 연결 타입 선택 구현 +- [ ] 기존 컴포넌트 리팩토링 + +### Week 2: 핵심 기능 + +- [ ] 단계별 진행 UI +- [ ] 연결/테이블 선택 통합 +- [ ] 기본 필드 매핑 구현 + +### Week 3: 시각적 개선 + +- [ ] SVG 연결선 시스템 +- [ ] 드래그 앤 드롭 매핑 +- [ ] 실시간 검증 기능 + +### Week 4: 완성 및 테스트 + +- [ ] 고급 기능 구현 +- [ ] 통합 테스트 +- [ ] 사용자 테스트 및 피드백 반영 + +## 🔍 기술적 고려사항 + +### 성능 최적화 + +- **가상화**: 대용량 필드 목록 처리 +- **메모이제이션**: 불필요한 리렌더링 방지 +- **지연 로딩**: 필요한 시점에만 데이터 로드 + +### 접근성 + +- **키보드 네비게이션**: 모든 기능을 키보드로 접근 가능 +- **스크린 리더**: 시각적 매핑의 대체 텍스트 제공 +- **색상 대비**: 연결선과 상태 표시의 명확한 구분 + +### 확장성 + +- **플러그인 구조**: 새로운 연결 타입 쉽게 추가 +- **커스텀 변환**: 사용자 정의 데이터 변환 규칙 +- **API 확장**: 외부 시스템과의 연동 지원 + +--- + +## 🎯 다음 단계 + +이 계획서를 바탕으로 **Phase 1부터 순차적으로 구현**을 시작하겠습니다. + +**첫 번째 작업**: 좌우 분할 레이아웃과 연결 타입 선택 컴포넌트 구현 + +구현을 시작하시겠어요? 🚀 diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index 495db410..a4bf97ba 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@prisma/client": "^5.7.1", + "@prisma/client": "^6.16.2", "@types/mssql": "^9.1.8", "axios": "^1.11.0", "bcryptjs": "^2.4.3", @@ -28,7 +28,6 @@ "nodemailer": "^6.9.7", "oracledb": "^6.9.0", "pg": "^8.16.3", - "prisma": "^5.7.1", "redis": "^4.6.10", "winston": "^3.11.0" }, @@ -55,6 +54,7 @@ "jest": "^29.7.0", "nodemon": "^3.1.10", "prettier": "^3.1.0", + "prisma": "^6.16.2", "supertest": "^6.3.3", "ts-jest": "^29.1.1", "ts-node": "^10.9.2", @@ -65,20 +65,6 @@ "npm": ">=10.0.0" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@aws-crypto/sha256-browser": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", @@ -215,51 +201,51 @@ } }, "node_modules/@aws-sdk/client-ses": { - "version": "3.864.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-ses/-/client-ses-3.864.0.tgz", - "integrity": "sha512-cmsOrJZsrNa892gD2cAsbVkweDulgmC8PE38cz//bM//1BW/R1MMFClapF+Q9gACtsRVTRBXNtsIsBq8Gm1Urw==", + "version": "3.896.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-ses/-/client-ses-3.896.0.tgz", + "integrity": "sha512-L5C1ZLdTnAAZJqngRxt6RB6boHnx1Jp1U/awmLsBcnW3tEax5iCLtaNkDRZ6XrccYktVcy2lIUXnFJ7G7WunoQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.864.0", - "@aws-sdk/credential-provider-node": "3.864.0", - "@aws-sdk/middleware-host-header": "3.862.0", - "@aws-sdk/middleware-logger": "3.862.0", - "@aws-sdk/middleware-recursion-detection": "3.862.0", - "@aws-sdk/middleware-user-agent": "3.864.0", - "@aws-sdk/region-config-resolver": "3.862.0", - "@aws-sdk/types": "3.862.0", - "@aws-sdk/util-endpoints": "3.862.0", - "@aws-sdk/util-user-agent-browser": "3.862.0", - "@aws-sdk/util-user-agent-node": "3.864.0", - "@smithy/config-resolver": "^4.1.5", - "@smithy/core": "^3.8.0", - "@smithy/fetch-http-handler": "^5.1.1", - "@smithy/hash-node": "^4.0.5", - "@smithy/invalid-dependency": "^4.0.5", - "@smithy/middleware-content-length": "^4.0.5", - "@smithy/middleware-endpoint": "^4.1.18", - "@smithy/middleware-retry": "^4.1.19", - "@smithy/middleware-serde": "^4.0.9", - "@smithy/middleware-stack": "^4.0.5", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/node-http-handler": "^4.1.1", - "@smithy/protocol-http": "^5.1.3", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", - "@smithy/url-parser": "^4.0.5", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-body-length-node": "^4.0.0", - "@smithy/util-defaults-mode-browser": "^4.0.26", - "@smithy/util-defaults-mode-node": "^4.0.26", - "@smithy/util-endpoints": "^3.0.7", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-retry": "^4.0.7", - "@smithy/util-utf8": "^4.0.0", - "@smithy/util-waiter": "^4.0.7", + "@aws-sdk/core": "3.896.0", + "@aws-sdk/credential-provider-node": "3.896.0", + "@aws-sdk/middleware-host-header": "3.893.0", + "@aws-sdk/middleware-logger": "3.893.0", + "@aws-sdk/middleware-recursion-detection": "3.893.0", + "@aws-sdk/middleware-user-agent": "3.896.0", + "@aws-sdk/region-config-resolver": "3.893.0", + "@aws-sdk/types": "3.893.0", + "@aws-sdk/util-endpoints": "3.895.0", + "@aws-sdk/util-user-agent-browser": "3.893.0", + "@aws-sdk/util-user-agent-node": "3.896.0", + "@smithy/config-resolver": "^4.2.2", + "@smithy/core": "^3.12.0", + "@smithy/fetch-http-handler": "^5.2.1", + "@smithy/hash-node": "^4.1.1", + "@smithy/invalid-dependency": "^4.1.1", + "@smithy/middleware-content-length": "^4.1.1", + "@smithy/middleware-endpoint": "^4.2.4", + "@smithy/middleware-retry": "^4.3.0", + "@smithy/middleware-serde": "^4.1.1", + "@smithy/middleware-stack": "^4.1.1", + "@smithy/node-config-provider": "^4.2.2", + "@smithy/node-http-handler": "^4.2.1", + "@smithy/protocol-http": "^5.2.1", + "@smithy/smithy-client": "^4.6.4", + "@smithy/types": "^4.5.0", + "@smithy/url-parser": "^4.1.1", + "@smithy/util-base64": "^4.1.0", + "@smithy/util-body-length-browser": "^4.1.0", + "@smithy/util-body-length-node": "^4.1.0", + "@smithy/util-defaults-mode-browser": "^4.1.4", + "@smithy/util-defaults-mode-node": "^4.1.4", + "@smithy/util-endpoints": "^3.1.2", + "@smithy/util-middleware": "^4.1.1", + "@smithy/util-retry": "^4.1.2", + "@smithy/util-utf8": "^4.1.0", + "@smithy/util-waiter": "^4.1.1", "tslib": "^2.6.2" }, "engines": { @@ -267,49 +253,49 @@ } }, "node_modules/@aws-sdk/client-sso": { - "version": "3.864.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.864.0.tgz", - "integrity": "sha512-THiOp0OpQROEKZ6IdDCDNNh3qnNn/kFFaTSOiugDpgcE5QdsOxh1/RXq7LmHpTJum3cmnFf8jG59PHcz9Tjnlw==", + "version": "3.896.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.896.0.tgz", + "integrity": "sha512-mpE3mrNili1dcvEvxaYjyoib8HlRXkb2bY5a3WeK++KObFY+HUujKtgQmiNSRX5YwQszm//fTrmGMmv9zpMcKg==", "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.864.0", - "@aws-sdk/middleware-host-header": "3.862.0", - "@aws-sdk/middleware-logger": "3.862.0", - "@aws-sdk/middleware-recursion-detection": "3.862.0", - "@aws-sdk/middleware-user-agent": "3.864.0", - "@aws-sdk/region-config-resolver": "3.862.0", - "@aws-sdk/types": "3.862.0", - "@aws-sdk/util-endpoints": "3.862.0", - "@aws-sdk/util-user-agent-browser": "3.862.0", - "@aws-sdk/util-user-agent-node": "3.864.0", - "@smithy/config-resolver": "^4.1.5", - "@smithy/core": "^3.8.0", - "@smithy/fetch-http-handler": "^5.1.1", - "@smithy/hash-node": "^4.0.5", - "@smithy/invalid-dependency": "^4.0.5", - "@smithy/middleware-content-length": "^4.0.5", - "@smithy/middleware-endpoint": "^4.1.18", - "@smithy/middleware-retry": "^4.1.19", - "@smithy/middleware-serde": "^4.0.9", - "@smithy/middleware-stack": "^4.0.5", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/node-http-handler": "^4.1.1", - "@smithy/protocol-http": "^5.1.3", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", - "@smithy/url-parser": "^4.0.5", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-body-length-node": "^4.0.0", - "@smithy/util-defaults-mode-browser": "^4.0.26", - "@smithy/util-defaults-mode-node": "^4.0.26", - "@smithy/util-endpoints": "^3.0.7", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-retry": "^4.0.7", - "@smithy/util-utf8": "^4.0.0", + "@aws-sdk/core": "3.896.0", + "@aws-sdk/middleware-host-header": "3.893.0", + "@aws-sdk/middleware-logger": "3.893.0", + "@aws-sdk/middleware-recursion-detection": "3.893.0", + "@aws-sdk/middleware-user-agent": "3.896.0", + "@aws-sdk/region-config-resolver": "3.893.0", + "@aws-sdk/types": "3.893.0", + "@aws-sdk/util-endpoints": "3.895.0", + "@aws-sdk/util-user-agent-browser": "3.893.0", + "@aws-sdk/util-user-agent-node": "3.896.0", + "@smithy/config-resolver": "^4.2.2", + "@smithy/core": "^3.12.0", + "@smithy/fetch-http-handler": "^5.2.1", + "@smithy/hash-node": "^4.1.1", + "@smithy/invalid-dependency": "^4.1.1", + "@smithy/middleware-content-length": "^4.1.1", + "@smithy/middleware-endpoint": "^4.2.4", + "@smithy/middleware-retry": "^4.3.0", + "@smithy/middleware-serde": "^4.1.1", + "@smithy/middleware-stack": "^4.1.1", + "@smithy/node-config-provider": "^4.2.2", + "@smithy/node-http-handler": "^4.2.1", + "@smithy/protocol-http": "^5.2.1", + "@smithy/smithy-client": "^4.6.4", + "@smithy/types": "^4.5.0", + "@smithy/url-parser": "^4.1.1", + "@smithy/util-base64": "^4.1.0", + "@smithy/util-body-length-browser": "^4.1.0", + "@smithy/util-body-length-node": "^4.1.0", + "@smithy/util-defaults-mode-browser": "^4.1.4", + "@smithy/util-defaults-mode-node": "^4.1.4", + "@smithy/util-endpoints": "^3.1.2", + "@smithy/util-middleware": "^4.1.1", + "@smithy/util-retry": "^4.1.2", + "@smithy/util-utf8": "^4.1.0", "tslib": "^2.6.2" }, "engines": { @@ -317,26 +303,24 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.864.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.864.0.tgz", - "integrity": "sha512-LFUREbobleHEln+Zf7IG83lAZwvHZG0stI7UU0CtwyuhQy5Yx0rKksHNOCmlM7MpTEbSCfntEhYi3jUaY5e5lg==", + "version": "3.896.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.896.0.tgz", + "integrity": "sha512-uJaoyWKeGNyCyeI+cIJrD7LEB4iF/W8/x2ij7zg32OFpAAJx96N34/e+XSKp/xkJpO5FKiBOskKLnHeUsJsAPA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.862.0", - "@aws-sdk/xml-builder": "3.862.0", - "@smithy/core": "^3.8.0", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/property-provider": "^4.0.5", - "@smithy/protocol-http": "^5.1.3", - "@smithy/signature-v4": "^5.1.3", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-utf8": "^4.0.0", - "fast-xml-parser": "5.2.5", + "@aws-sdk/types": "3.893.0", + "@aws-sdk/xml-builder": "3.894.0", + "@smithy/core": "^3.12.0", + "@smithy/node-config-provider": "^4.2.2", + "@smithy/property-provider": "^4.1.1", + "@smithy/protocol-http": "^5.2.1", + "@smithy/signature-v4": "^5.2.1", + "@smithy/smithy-client": "^4.6.4", + "@smithy/types": "^4.5.0", + "@smithy/util-base64": "^4.1.0", + "@smithy/util-middleware": "^4.1.1", + "@smithy/util-utf8": "^4.1.0", "tslib": "^2.6.2" }, "engines": { @@ -344,16 +328,16 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.864.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.864.0.tgz", - "integrity": "sha512-StJPOI2Rt8UE6lYjXUpg6tqSZaM72xg46ljPg8kIevtBAAfdtq9K20qT/kSliWGIBocMFAv0g2mC0hAa+ECyvg==", + "version": "3.896.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.896.0.tgz", + "integrity": "sha512-Cnqhupdkp825ICySrz4QTI64Nq3AmUAscPW8dueanni0avYBDp7RBppX4H0+6icqN569B983XNfQ0YSImQhfhg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.864.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/types": "^4.3.2", + "@aws-sdk/core": "3.896.0", + "@aws-sdk/types": "3.893.0", + "@smithy/property-provider": "^4.1.1", + "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, "engines": { @@ -361,21 +345,21 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.864.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.864.0.tgz", - "integrity": "sha512-E/RFVxGTuGnuD+9pFPH2j4l6HvrXzPhmpL8H8nOoJUosjx7d4v93GJMbbl1v/fkDLqW9qN4Jx2cI6PAjohA6OA==", + "version": "3.896.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.896.0.tgz", + "integrity": "sha512-CN0fTCKCUA1OTSx1c76o8XyJCy2WoI/av3J8r8mL6GmxTerhLRyzDy/MwxzPjTYPoL+GLEg6V4a9fRkWj1hBUA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.864.0", - "@aws-sdk/types": "3.862.0", - "@smithy/fetch-http-handler": "^5.1.1", - "@smithy/node-http-handler": "^4.1.1", - "@smithy/property-provider": "^4.0.5", - "@smithy/protocol-http": "^5.1.3", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", - "@smithy/util-stream": "^4.2.4", + "@aws-sdk/core": "3.896.0", + "@aws-sdk/types": "3.893.0", + "@smithy/fetch-http-handler": "^5.2.1", + "@smithy/node-http-handler": "^4.2.1", + "@smithy/property-provider": "^4.1.1", + "@smithy/protocol-http": "^5.2.1", + "@smithy/smithy-client": "^4.6.4", + "@smithy/types": "^4.5.0", + "@smithy/util-stream": "^4.3.2", "tslib": "^2.6.2" }, "engines": { @@ -383,24 +367,24 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.864.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.864.0.tgz", - "integrity": "sha512-PlxrijguR1gxyPd5EYam6OfWLarj2MJGf07DvCx9MAuQkw77HBnsu6+XbV8fQriFuoJVTBLn9ROhMr/ROAYfUg==", + "version": "3.896.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.896.0.tgz", + "integrity": "sha512-+rbYG98czzwZLTYHJasK+VBjnIeXk73mRpZXHvaa4kDNxBezdN2YsoGNpLlPSxPdbpq18LY3LRtkdFTaT6DIQA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.864.0", - "@aws-sdk/credential-provider-env": "3.864.0", - "@aws-sdk/credential-provider-http": "3.864.0", - "@aws-sdk/credential-provider-process": "3.864.0", - "@aws-sdk/credential-provider-sso": "3.864.0", - "@aws-sdk/credential-provider-web-identity": "3.864.0", - "@aws-sdk/nested-clients": "3.864.0", - "@aws-sdk/types": "3.862.0", - "@smithy/credential-provider-imds": "^4.0.7", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", + "@aws-sdk/core": "3.896.0", + "@aws-sdk/credential-provider-env": "3.896.0", + "@aws-sdk/credential-provider-http": "3.896.0", + "@aws-sdk/credential-provider-process": "3.896.0", + "@aws-sdk/credential-provider-sso": "3.896.0", + "@aws-sdk/credential-provider-web-identity": "3.896.0", + "@aws-sdk/nested-clients": "3.896.0", + "@aws-sdk/types": "3.893.0", + "@smithy/credential-provider-imds": "^4.1.2", + "@smithy/property-provider": "^4.1.1", + "@smithy/shared-ini-file-loader": "^4.2.0", + "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, "engines": { @@ -408,23 +392,23 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.864.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.864.0.tgz", - "integrity": "sha512-2BEymFeXURS+4jE9tP3vahPwbYRl0/1MVaFZcijj6pq+nf5EPGvkFillbdBRdc98ZI2NedZgSKu3gfZXgYdUhQ==", + "version": "3.896.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.896.0.tgz", + "integrity": "sha512-J0Jm+56MNngk1PIyqoJFf5FC2fjA4CYXlqODqNRDtid7yk7HB9W3UTtvxofmii5KJOLcHGNPdGnHWKkUc+xYgw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "3.864.0", - "@aws-sdk/credential-provider-http": "3.864.0", - "@aws-sdk/credential-provider-ini": "3.864.0", - "@aws-sdk/credential-provider-process": "3.864.0", - "@aws-sdk/credential-provider-sso": "3.864.0", - "@aws-sdk/credential-provider-web-identity": "3.864.0", - "@aws-sdk/types": "3.862.0", - "@smithy/credential-provider-imds": "^4.0.7", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", + "@aws-sdk/credential-provider-env": "3.896.0", + "@aws-sdk/credential-provider-http": "3.896.0", + "@aws-sdk/credential-provider-ini": "3.896.0", + "@aws-sdk/credential-provider-process": "3.896.0", + "@aws-sdk/credential-provider-sso": "3.896.0", + "@aws-sdk/credential-provider-web-identity": "3.896.0", + "@aws-sdk/types": "3.893.0", + "@smithy/credential-provider-imds": "^4.1.2", + "@smithy/property-provider": "^4.1.1", + "@smithy/shared-ini-file-loader": "^4.2.0", + "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, "engines": { @@ -432,17 +416,17 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.864.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.864.0.tgz", - "integrity": "sha512-Zxnn1hxhq7EOqXhVYgkF4rI9MnaO3+6bSg/tErnBQ3F8kDpA7CFU24G1YxwaJXp2X4aX3LwthefmSJHwcVP/2g==", + "version": "3.896.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.896.0.tgz", + "integrity": "sha512-UfWVMQPZy7dus40c4LWxh5vQ+I51z0q4vf09Eqas5848e9DrGRG46GYIuc/gy+4CqEypjbg/XNMjnZfGLHxVnQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.864.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", + "@aws-sdk/core": "3.896.0", + "@aws-sdk/types": "3.893.0", + "@smithy/property-provider": "^4.1.1", + "@smithy/shared-ini-file-loader": "^4.2.0", + "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, "engines": { @@ -450,19 +434,19 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.864.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.864.0.tgz", - "integrity": "sha512-UPyPNQbxDwHVGmgWdGg9/9yvzuedRQVF5jtMkmP565YX9pKZ8wYAcXhcYdNPWFvH0GYdB0crKOmvib+bmCuwkw==", + "version": "3.896.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.896.0.tgz", + "integrity": "sha512-77Te8WrVdLABKlv7QyetXP6aYEX1UORiahLA1PXQb/p66aFBw18Xc6JiN/6zJ4RqdyV1Xr9rwYBwGYua93ANIA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/client-sso": "3.864.0", - "@aws-sdk/core": "3.864.0", - "@aws-sdk/token-providers": "3.864.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", + "@aws-sdk/client-sso": "3.896.0", + "@aws-sdk/core": "3.896.0", + "@aws-sdk/token-providers": "3.896.0", + "@aws-sdk/types": "3.893.0", + "@smithy/property-provider": "^4.1.1", + "@smithy/shared-ini-file-loader": "^4.2.0", + "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, "engines": { @@ -470,17 +454,18 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.864.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.864.0.tgz", - "integrity": "sha512-nNcjPN4SYg8drLwqK0vgVeSvxeGQiD0FxOaT38mV2H8cu0C5NzpvA+14Xy+W6vT84dxgmJYKk71Cr5QL2Oz+rA==", + "version": "3.896.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.896.0.tgz", + "integrity": "sha512-gwMwZWumo+V0xJplO8j2HIb1TfPsF9fbcRGXS0CanEvjg4fF2Xs1pOQl2oCw3biPZpxHB0plNZjqSF2eneGg9g==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.864.0", - "@aws-sdk/nested-clients": "3.864.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/types": "^4.3.2", + "@aws-sdk/core": "3.896.0", + "@aws-sdk/nested-clients": "3.896.0", + "@aws-sdk/types": "3.893.0", + "@smithy/property-provider": "^4.1.1", + "@smithy/shared-ini-file-loader": "^4.2.0", + "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, "engines": { @@ -488,15 +473,15 @@ } }, "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.862.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.862.0.tgz", - "integrity": "sha512-jDje8dCFeFHfuCAxMDXBs8hy8q9NCTlyK4ThyyfAj3U4Pixly2mmzY2u7b7AyGhWsjJNx8uhTjlYq5zkQPQCYw==", + "version": "3.893.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.893.0.tgz", + "integrity": "sha512-qL5xYRt80ahDfj9nDYLhpCNkDinEXvjLe/Qen/Y/u12+djrR2MB4DRa6mzBCkLkdXDtf0WAoW2EZsNCfGrmOEQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.862.0", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", + "@aws-sdk/types": "3.893.0", + "@smithy/protocol-http": "^5.2.1", + "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, "engines": { @@ -504,14 +489,14 @@ } }, "node_modules/@aws-sdk/middleware-logger": { - "version": "3.862.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.862.0.tgz", - "integrity": "sha512-N/bXSJznNBR/i7Ofmf9+gM6dx/SPBK09ZWLKsW5iQjqKxAKn/2DozlnE54uiEs1saHZWoNDRg69Ww4XYYSlG1Q==", + "version": "3.893.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.893.0.tgz", + "integrity": "sha512-ZqzMecjju5zkBquSIfVfCORI/3Mge21nUY4nWaGQy+NUXehqCGG4W7AiVpiHGOcY2cGJa7xeEkYcr2E2U9U0AA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.862.0", - "@smithy/types": "^4.3.2", + "@aws-sdk/types": "3.893.0", + "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, "engines": { @@ -519,15 +504,16 @@ } }, "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.862.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.862.0.tgz", - "integrity": "sha512-KVoo3IOzEkTq97YKM4uxZcYFSNnMkhW/qj22csofLegZi5fk90ztUnnaeKfaEJHfHp/tm1Y3uSoOXH45s++kKQ==", + "version": "3.893.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.893.0.tgz", + "integrity": "sha512-H7Zotd9zUHQAr/wr3bcWHULYhEeoQrF54artgsoUGIf/9emv6LzY89QUccKIxYd6oHKNTrTyXm9F0ZZrzXNxlg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.862.0", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", + "@aws-sdk/types": "3.893.0", + "@aws/lambda-invoke-store": "^0.0.1", + "@smithy/protocol-http": "^5.2.1", + "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, "engines": { @@ -535,18 +521,18 @@ } }, "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.864.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.864.0.tgz", - "integrity": "sha512-wrddonw4EyLNSNBrApzEhpSrDwJiNfjxDm5E+bn8n32BbAojXASH8W8jNpxz/jMgNkkJNxCfyqybGKzBX0OhbQ==", + "version": "3.896.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.896.0.tgz", + "integrity": "sha512-so/3tZH34YIeqG/QJgn5ZinnmHRdXV1ehsj4wVUrezL/dVW86jfwIkQIwpw8roOC657UoUf91c9FDhCxs3J5aQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.864.0", - "@aws-sdk/types": "3.862.0", - "@aws-sdk/util-endpoints": "3.862.0", - "@smithy/core": "^3.8.0", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", + "@aws-sdk/core": "3.896.0", + "@aws-sdk/types": "3.893.0", + "@aws-sdk/util-endpoints": "3.895.0", + "@smithy/core": "^3.12.0", + "@smithy/protocol-http": "^5.2.1", + "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, "engines": { @@ -554,49 +540,49 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.864.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.864.0.tgz", - "integrity": "sha512-H1C+NjSmz2y8Tbgh7Yy89J20yD/hVyk15hNoZDbCYkXg0M358KS7KVIEYs8E2aPOCr1sK3HBE819D/yvdMgokA==", + "version": "3.896.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.896.0.tgz", + "integrity": "sha512-KaHALB6DIXScJL/ExmonADr3jtTV6dpOHoEeTRSskJ/aW+rhZo7kH8SLmrwOT/qX8d5tza17YyR/oRkIKY6Eaw==", "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.864.0", - "@aws-sdk/middleware-host-header": "3.862.0", - "@aws-sdk/middleware-logger": "3.862.0", - "@aws-sdk/middleware-recursion-detection": "3.862.0", - "@aws-sdk/middleware-user-agent": "3.864.0", - "@aws-sdk/region-config-resolver": "3.862.0", - "@aws-sdk/types": "3.862.0", - "@aws-sdk/util-endpoints": "3.862.0", - "@aws-sdk/util-user-agent-browser": "3.862.0", - "@aws-sdk/util-user-agent-node": "3.864.0", - "@smithy/config-resolver": "^4.1.5", - "@smithy/core": "^3.8.0", - "@smithy/fetch-http-handler": "^5.1.1", - "@smithy/hash-node": "^4.0.5", - "@smithy/invalid-dependency": "^4.0.5", - "@smithy/middleware-content-length": "^4.0.5", - "@smithy/middleware-endpoint": "^4.1.18", - "@smithy/middleware-retry": "^4.1.19", - "@smithy/middleware-serde": "^4.0.9", - "@smithy/middleware-stack": "^4.0.5", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/node-http-handler": "^4.1.1", - "@smithy/protocol-http": "^5.1.3", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", - "@smithy/url-parser": "^4.0.5", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-body-length-node": "^4.0.0", - "@smithy/util-defaults-mode-browser": "^4.0.26", - "@smithy/util-defaults-mode-node": "^4.0.26", - "@smithy/util-endpoints": "^3.0.7", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-retry": "^4.0.7", - "@smithy/util-utf8": "^4.0.0", + "@aws-sdk/core": "3.896.0", + "@aws-sdk/middleware-host-header": "3.893.0", + "@aws-sdk/middleware-logger": "3.893.0", + "@aws-sdk/middleware-recursion-detection": "3.893.0", + "@aws-sdk/middleware-user-agent": "3.896.0", + "@aws-sdk/region-config-resolver": "3.893.0", + "@aws-sdk/types": "3.893.0", + "@aws-sdk/util-endpoints": "3.895.0", + "@aws-sdk/util-user-agent-browser": "3.893.0", + "@aws-sdk/util-user-agent-node": "3.896.0", + "@smithy/config-resolver": "^4.2.2", + "@smithy/core": "^3.12.0", + "@smithy/fetch-http-handler": "^5.2.1", + "@smithy/hash-node": "^4.1.1", + "@smithy/invalid-dependency": "^4.1.1", + "@smithy/middleware-content-length": "^4.1.1", + "@smithy/middleware-endpoint": "^4.2.4", + "@smithy/middleware-retry": "^4.3.0", + "@smithy/middleware-serde": "^4.1.1", + "@smithy/middleware-stack": "^4.1.1", + "@smithy/node-config-provider": "^4.2.2", + "@smithy/node-http-handler": "^4.2.1", + "@smithy/protocol-http": "^5.2.1", + "@smithy/smithy-client": "^4.6.4", + "@smithy/types": "^4.5.0", + "@smithy/url-parser": "^4.1.1", + "@smithy/util-base64": "^4.1.0", + "@smithy/util-body-length-browser": "^4.1.0", + "@smithy/util-body-length-node": "^4.1.0", + "@smithy/util-defaults-mode-browser": "^4.1.4", + "@smithy/util-defaults-mode-node": "^4.1.4", + "@smithy/util-endpoints": "^3.1.2", + "@smithy/util-middleware": "^4.1.1", + "@smithy/util-retry": "^4.1.2", + "@smithy/util-utf8": "^4.1.0", "tslib": "^2.6.2" }, "engines": { @@ -604,17 +590,17 @@ } }, "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.862.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.862.0.tgz", - "integrity": "sha512-VisR+/HuVFICrBPY+q9novEiE4b3mvDofWqyvmxHcWM7HumTz9ZQSuEtnlB/92GVM3KDUrR9EmBHNRrfXYZkcQ==", + "version": "3.893.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.893.0.tgz", + "integrity": "sha512-/cJvh3Zsa+Of0Zbg7vl9wp/kZtdb40yk/2+XcroAMVPO9hPvmS9r/UOm6tO7FeX4TtkRFwWaQJiTZTgSdsPY+Q==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.862.0", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/types": "^4.3.2", - "@smithy/util-config-provider": "^4.0.0", - "@smithy/util-middleware": "^4.0.5", + "@aws-sdk/types": "3.893.0", + "@smithy/node-config-provider": "^4.2.2", + "@smithy/types": "^4.5.0", + "@smithy/util-config-provider": "^4.1.0", + "@smithy/util-middleware": "^4.1.1", "tslib": "^2.6.2" }, "engines": { @@ -622,18 +608,18 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.864.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.864.0.tgz", - "integrity": "sha512-gTc2QHOBo05SCwVA65dUtnJC6QERvFaPiuppGDSxoF7O5AQNK0UR/kMSenwLqN8b5E1oLYvQTv3C1idJLRX0cg==", + "version": "3.896.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.896.0.tgz", + "integrity": "sha512-WBoD+RY7tUfW9M+wGrZ2vdveR+ziZOjGHWFY3lcGnDvI8KE+fcSccEOTxgJBNBS5Z8B+WHKU2sZjb+Z7QqGwjw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.864.0", - "@aws-sdk/nested-clients": "3.864.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", + "@aws-sdk/core": "3.896.0", + "@aws-sdk/nested-clients": "3.896.0", + "@aws-sdk/types": "3.893.0", + "@smithy/property-provider": "^4.1.1", + "@smithy/shared-ini-file-loader": "^4.2.0", + "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, "engines": { @@ -641,13 +627,13 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.862.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.862.0.tgz", - "integrity": "sha512-Bei+RL0cDxxV+lW2UezLbCYYNeJm6Nzee0TpW0FfyTRBhH9C1XQh4+x+IClriXvgBnRquTMMYsmJfvx8iyLKrg==", + "version": "3.893.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.893.0.tgz", + "integrity": "sha512-Aht1nn5SnA0N+Tjv0dzhAY7CQbxVtmq1bBR6xI0MhG7p2XYVh1wXuKTzrldEvQWwA3odOYunAfT9aBiKZx9qIg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.2", + "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, "engines": { @@ -655,16 +641,16 @@ } }, "node_modules/@aws-sdk/util-endpoints": { - "version": "3.862.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.862.0.tgz", - "integrity": "sha512-eCZuScdE9MWWkHGM2BJxm726MCmWk/dlHjOKvkM0sN1zxBellBMw5JohNss1Z8/TUmnW2gb9XHTOiHuGjOdksA==", + "version": "3.895.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.895.0.tgz", + "integrity": "sha512-MhxBvWbwxmKknuggO2NeMwOVkHOYL98pZ+1ZRI5YwckoCL3AvISMnPJgfN60ww6AIXHGpkp+HhpFdKOe8RHSEg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.862.0", - "@smithy/types": "^4.3.2", - "@smithy/url-parser": "^4.0.5", - "@smithy/util-endpoints": "^3.0.7", + "@aws-sdk/types": "3.893.0", + "@smithy/types": "^4.5.0", + "@smithy/url-parser": "^4.1.1", + "@smithy/util-endpoints": "^3.1.2", "tslib": "^2.6.2" }, "engines": { @@ -672,9 +658,9 @@ } }, "node_modules/@aws-sdk/util-locate-window": { - "version": "3.804.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.804.0.tgz", - "integrity": "sha512-zVoRfpmBVPodYlnMjgVjfGoEZagyRF5IPn3Uo6ZvOZp24chnW/FRstH7ESDHDDRga4z3V+ElUQHKpFDXWyBW5A==", + "version": "3.893.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.893.0.tgz", + "integrity": "sha512-T89pFfgat6c8nMmpI8eKjBcDcgJq36+m9oiXbcUzeU55MP9ZuGgBomGjGnHaEyF36jenW9gmg3NfZDm0AO2XPg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -685,29 +671,29 @@ } }, "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.862.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.862.0.tgz", - "integrity": "sha512-BmPTlm0r9/10MMr5ND9E92r8KMZbq5ltYXYpVcUbAsnB1RJ8ASJuRoLne5F7mB3YMx0FJoOTuSq7LdQM3LgW3Q==", + "version": "3.893.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.893.0.tgz", + "integrity": "sha512-PE9NtbDBW6Kgl1bG6A5fF3EPo168tnkj8TgMcT0sg4xYBWsBpq0bpJZRh+Jm5Bkwiw9IgTCLjEU7mR6xWaMB9w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.862.0", - "@smithy/types": "^4.3.2", + "@aws-sdk/types": "3.893.0", + "@smithy/types": "^4.5.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.864.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.864.0.tgz", - "integrity": "sha512-d+FjUm2eJEpP+FRpVR3z6KzMdx1qwxEYDz8jzNKwxYLBBquaBaP/wfoMtMQKAcbrR7aT9FZVZF7zDgzNxUvQlQ==", + "version": "3.896.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.896.0.tgz", + "integrity": "sha512-jegizucAwoxyBddKl0kRGNEgRHcfGuMeyhP1Nf+wIUmHz/9CxobIajqcVk/KRNLdZY5mSn7YG2VtP3z0BcBb0w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-user-agent": "3.864.0", - "@aws-sdk/types": "3.862.0", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/types": "^4.3.2", + "@aws-sdk/middleware-user-agent": "3.896.0", + "@aws-sdk/types": "3.893.0", + "@smithy/node-config-provider": "^4.2.2", + "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, "engines": { @@ -723,19 +709,30 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.862.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.862.0.tgz", - "integrity": "sha512-6Ed0kmC1NMbuFTEgNmamAUU1h5gShgxL1hBVLbEzUa3trX5aJBz1vU4bXaBTvOYUAnOHtiy1Ml4AMStd6hJnFA==", + "version": "3.894.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.894.0.tgz", + "integrity": "sha512-E6EAMc9dT1a2DOdo4zyOf3fp5+NJ2wI+mcm7RaW1baFIWDwcb99PpvWoV7YEiK7oaBDshuOEGWKUSYXdW+JYgA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.2", + "@smithy/types": "^4.5.0", + "fast-xml-parser": "5.2.5", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.0.1.tgz", + "integrity": "sha512-ORHRQ2tmvnBXc8t/X9Z8IcSbBA4xTLKuN873FopzklHMeqBst7YG0d+AX97inkvDX+NChYtSr+qGfcqGFaI8Zw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@azure-rest/core-client": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@azure-rest/core-client/-/core-client-2.5.1.tgz", @@ -960,33 +957,33 @@ } }, "node_modules/@azure/msal-browser": { - "version": "4.23.0", - "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.23.0.tgz", - "integrity": "sha512-uHnfRwGAEHaYVXzpCtYsruy6PQxL2v76+MJ3+n/c/3PaTiTIa5ch7VofTUNoA39nHyjJbdiqTwFZK40OOTOkjw==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.24.0.tgz", + "integrity": "sha512-BNoiUEx4olj16U9ZiquvIhG1dZBnwWSzSXiSclq/9qiFQXYeLOKqEaEv98+xLXJ3oLw9APwHTR1eY2Qk0v6XBQ==", "license": "MIT", "dependencies": { - "@azure/msal-common": "15.12.0" + "@azure/msal-common": "15.13.0" }, "engines": { "node": ">=0.8.0" } }, "node_modules/@azure/msal-common": { - "version": "15.12.0", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.12.0.tgz", - "integrity": "sha512-4ucXbjVw8KJ5QBgnGJUeA07c8iznwlk5ioHIhI4ASXcXgcf2yRFhWzYOyWg/cI49LC9ekpFJeQtO3zjDTbl6TQ==", + "version": "15.13.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.13.0.tgz", + "integrity": "sha512-8oF6nj02qX7eE/6+wFT5NluXRHc05AgdCC3fJnkjiJooq8u7BcLmxaYYSwc2AfEkWRMRi6Eyvvbeqk4U4412Ag==", "license": "MIT", "engines": { "node": ">=0.8.0" } }, "node_modules/@azure/msal-node": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-3.7.4.tgz", - "integrity": "sha512-fjqvhrThwzzPvqhFOdkkGRJCHPQZTNijpceVy8QjcfQuH482tOVEjHyamZaioOhVtx+FK1u+eMpJA2Zz4U9LVg==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-3.8.0.tgz", + "integrity": "sha512-23BXm82Mp5XnRhrcd4mrHa0xuUNRp96ivu3nRatrfdAqjoeWAGyD0eEAafxAOHAEWWmdlyFK4ELFcdziXyw2sA==", "license": "MIT", "dependencies": { - "@azure/msal-common": "15.12.0", + "@azure/msal-common": "15.13.0", "jsonwebtoken": "^9.0.0", "uuid": "^8.3.0" }, @@ -994,15 +991,6 @@ "node": ">=16" } }, - "node_modules/@azure/msal-node/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -1019,9 +1007,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", - "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", + "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", "dev": true, "license": "MIT", "engines": { @@ -1029,22 +1017,22 @@ } }, "node_modules/@babel/core": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz", - "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", + "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.3", - "@babel/parser": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.3", - "@babel/types": "^7.28.2", + "@babel/traverse": "^7.28.4", + "@babel/types": "^7.28.4", + "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -1196,27 +1184,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.3.tgz", - "integrity": "sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.27.2", - "@babel/types": "^7.28.2" + "@babel/types": "^7.28.4" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz", - "integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.2" + "@babel/types": "^7.28.4" }, "bin": { "parser": "bin/babel-parser.js" @@ -1480,18 +1468,18 @@ } }, "node_modules/@babel/traverse": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.3.tgz", - "integrity": "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", + "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.3", + "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", - "@babel/types": "^7.28.2", + "@babel/types": "^7.28.4", "debug": "^4.3.1" }, "engines": { @@ -1499,9 +1487,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.2", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", - "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1564,9 +1552,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", - "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", "dev": true, "license": "MIT", "dependencies": { @@ -1834,6 +1822,13 @@ "node": ">=8" } }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", @@ -2147,6 +2142,17 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -2165,9 +2171,9 @@ "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.30", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", - "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", "dependencies": { @@ -2243,66 +2249,88 @@ } }, "node_modules/@prisma/client": { - "version": "5.22.0", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", - "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", + "version": "6.16.2", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.16.2.tgz", + "integrity": "sha512-E00PxBcalMfYO/TWnXobBVUai6eW/g5OsifWQsQDzJYm7yaY+IRLo7ZLsaefi0QkTpxfuhFcQ/w180i6kX3iJw==", "hasInstallScript": true, "license": "Apache-2.0", "engines": { - "node": ">=16.13" + "node": ">=18.18" }, "peerDependencies": { - "prisma": "*" + "prisma": "*", + "typescript": ">=5.1.0" }, "peerDependenciesMeta": { "prisma": { "optional": true + }, + "typescript": { + "optional": true } } }, + "node_modules/@prisma/config": { + "version": "6.16.2", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.16.2.tgz", + "integrity": "sha512-mKXSUrcqXj0LXWPmJsK2s3p9PN+aoAbyMx7m5E1v1FufofR1ZpPoIArjjzOIm+bJRLLvYftoNYLx1tbHgF9/yg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.16.12", + "empathic": "2.0.0" + } + }, "node_modules/@prisma/debug": { - "version": "5.22.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", - "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", + "version": "6.16.2", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.16.2.tgz", + "integrity": "sha512-bo4/gA/HVV6u8YK2uY6glhNsJ7r+k/i5iQ9ny/3q5bt9ijCj7WMPUwfTKPvtEgLP+/r26Z686ly11hhcLiQ8zA==", + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines": { - "version": "5.22.0", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", - "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", + "version": "6.16.2", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.16.2.tgz", + "integrity": "sha512-7yf3AjfPUgsg/l7JSu1iEhsmZZ/YE00yURPjTikqm2z4btM0bCl2coFtTGfeSOWbQMmq45Jab+53yGUIAT1sjA==", + "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "5.22.0", - "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", - "@prisma/fetch-engine": "5.22.0", - "@prisma/get-platform": "5.22.0" + "@prisma/debug": "6.16.2", + "@prisma/engines-version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43", + "@prisma/fetch-engine": "6.16.2", + "@prisma/get-platform": "6.16.2" } }, "node_modules/@prisma/engines-version": { - "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", - "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", + "version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43.tgz", + "integrity": "sha512-ThvlDaKIVrnrv97ujNFDYiQbeMQpLa0O86HFA2mNoip4mtFqM7U5GSz2ie1i2xByZtvPztJlNRgPsXGeM/kqAA==", + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine": { - "version": "5.22.0", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", - "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", + "version": "6.16.2", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.16.2.tgz", + "integrity": "sha512-wPnZ8DMRqpgzye758ZvfAMiNJRuYpz+rhgEBZi60ZqDIgOU2694oJxiuu3GKFeYeR/hXxso4/2oBC243t/whxQ==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "5.22.0", - "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", - "@prisma/get-platform": "5.22.0" + "@prisma/debug": "6.16.2", + "@prisma/engines-version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43", + "@prisma/get-platform": "6.16.2" } }, "node_modules/@prisma/get-platform": { - "version": "5.22.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", - "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", + "version": "6.16.2", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.16.2.tgz", + "integrity": "sha512-U/P36Uke5wS7r1+omtAgJpEB94tlT4SdlgaeTc6HVTTT93pXj7zZ+B/cZnmnvjcNPfWddgoDx8RLjmQwqGDYyA==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "5.22.0" + "@prisma/debug": "6.16.2" } }, "node_modules/@redis/bloom": { @@ -2419,13 +2447,13 @@ } }, "node_modules/@smithy/abort-controller": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.0.5.tgz", - "integrity": "sha512-jcrqdTQurIrBbUm4W2YdLVMQDoL0sA9DTxYd2s+R/y+2U9NLOP7Xf/YqfSg1FZhlZIYEnvk2mwbyvIfdLEPo8g==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.1.1.tgz", + "integrity": "sha512-vkzula+IwRvPR6oKQhMYioM3A/oX/lFCZiwuxkQbRhqJS2S4YRY2k7k/SyR2jMf3607HLtbEwlRxi0ndXHMjRg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.2", + "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, "engines": { @@ -2433,16 +2461,16 @@ } }, "node_modules/@smithy/config-resolver": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.1.5.tgz", - "integrity": "sha512-viuHMxBAqydkB0AfWwHIdwf/PRH2z5KHGUzqyRtS/Wv+n3IHI993Sk76VCA7dD/+GzgGOmlJDITfPcJC1nIVIw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.2.2.tgz", + "integrity": "sha512-IT6MatgBWagLybZl1xQcURXRICvqz1z3APSCAI9IqdvfCkrA7RaQIEfgC6G/KvfxnDfQUDqFV+ZlixcuFznGBQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.1.4", - "@smithy/types": "^4.3.2", - "@smithy/util-config-provider": "^4.0.0", - "@smithy/util-middleware": "^4.0.5", + "@smithy/node-config-provider": "^4.2.2", + "@smithy/types": "^4.5.0", + "@smithy/util-config-provider": "^4.1.0", + "@smithy/util-middleware": "^4.1.1", "tslib": "^2.6.2" }, "engines": { @@ -2450,39 +2478,38 @@ } }, "node_modules/@smithy/core": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.8.0.tgz", - "integrity": "sha512-EYqsIYJmkR1VhVE9pccnk353xhs+lB6btdutJEtsp7R055haMJp2yE16eSxw8fv+G0WUY6vqxyYOP8kOqawxYQ==", + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.12.0.tgz", + "integrity": "sha512-zJeAgogZfbwlPGL93y4Z/XNeIN37YCreRUd6YMIRvaq+6RnBK8PPYYIQ85Is/GglPh3kNImD5riDCXbVSDpCiQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/middleware-serde": "^4.0.9", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-stream": "^4.2.4", - "@smithy/util-utf8": "^4.0.0", - "@types/uuid": "^9.0.1", - "tslib": "^2.6.2", - "uuid": "^9.0.1" + "@smithy/middleware-serde": "^4.1.1", + "@smithy/protocol-http": "^5.2.1", + "@smithy/types": "^4.5.0", + "@smithy/util-base64": "^4.1.0", + "@smithy/util-body-length-browser": "^4.1.0", + "@smithy/util-middleware": "^4.1.1", + "@smithy/util-stream": "^4.3.2", + "@smithy/util-utf8": "^4.1.0", + "@smithy/uuid": "^1.0.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.0.7.tgz", - "integrity": "sha512-dDzrMXA8d8riFNiPvytxn0mNwR4B3h8lgrQ5UjAGu6T9z/kRg/Xncf4tEQHE/+t25sY8IH3CowcmWi+1U5B1Gw==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.1.2.tgz", + "integrity": "sha512-JlYNq8TShnqCLg0h+afqe2wLAwZpuoSgOyzhYvTgbiKBWRov+uUve+vrZEQO6lkdLOWPh7gK5dtb9dS+KGendg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.1.4", - "@smithy/property-provider": "^4.0.5", - "@smithy/types": "^4.3.2", - "@smithy/url-parser": "^4.0.5", + "@smithy/node-config-provider": "^4.2.2", + "@smithy/property-provider": "^4.1.1", + "@smithy/types": "^4.5.0", + "@smithy/url-parser": "^4.1.1", "tslib": "^2.6.2" }, "engines": { @@ -2490,16 +2517,16 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.1.1.tgz", - "integrity": "sha512-61WjM0PWmZJR+SnmzaKI7t7G0UkkNFboDpzIdzSoy7TByUzlxo18Qlh9s71qug4AY4hlH/CwXdubMtkcNEb/sQ==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.2.1.tgz", + "integrity": "sha512-5/3wxKNtV3wO/hk1is+CZUhL8a1yy/U+9u9LKQ9kZTkMsHaQjJhc3stFfiujtMnkITjzWfndGA2f7g9Uh9vKng==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.1.3", - "@smithy/querystring-builder": "^4.0.5", - "@smithy/types": "^4.3.2", - "@smithy/util-base64": "^4.0.0", + "@smithy/protocol-http": "^5.2.1", + "@smithy/querystring-builder": "^4.1.1", + "@smithy/types": "^4.5.0", + "@smithy/util-base64": "^4.1.0", "tslib": "^2.6.2" }, "engines": { @@ -2507,15 +2534,15 @@ } }, "node_modules/@smithy/hash-node": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.0.5.tgz", - "integrity": "sha512-cv1HHkKhpyRb6ahD8Vcfb2Hgz67vNIXEp2vnhzfxLFGRukLCNEA5QdsorbUEzXma1Rco0u3rx5VTqbM06GcZqQ==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.1.1.tgz", + "integrity": "sha512-H9DIU9WBLhYrvPs9v4sYvnZ1PiAI0oc8CgNQUJ1rpN3pP7QADbTOUjchI2FB764Ub0DstH5xbTqcMJu1pnVqxA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.2", - "@smithy/util-buffer-from": "^4.0.0", - "@smithy/util-utf8": "^4.0.0", + "@smithy/types": "^4.5.0", + "@smithy/util-buffer-from": "^4.1.0", + "@smithy/util-utf8": "^4.1.0", "tslib": "^2.6.2" }, "engines": { @@ -2523,13 +2550,13 @@ } }, "node_modules/@smithy/invalid-dependency": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.0.5.tgz", - "integrity": "sha512-IVnb78Qtf7EJpoEVo7qJ8BEXQwgC4n3igeJNNKEj/MLYtapnx8A67Zt/J3RXAj2xSO1910zk0LdFiygSemuLow==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.1.1.tgz", + "integrity": "sha512-1AqLyFlfrrDkyES8uhINRlJXmHA2FkG+3DY8X+rmLSqmFwk3DJnvhyGzyByPyewh2jbmV+TYQBEfngQax8IFGg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.2", + "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, "engines": { @@ -2537,9 +2564,9 @@ } }, "node_modules/@smithy/is-array-buffer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.0.0.tgz", - "integrity": "sha512-saYhF8ZZNoJDTvJBEWgeBccCg+yvp1CX+ed12yORU3NilJScfc6gfch2oVb4QgxZrGUx3/ZJlb+c/dJbyupxlw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.1.0.tgz", + "integrity": "sha512-ePTYUOV54wMogio+he4pBybe8fwg4sDvEVDBU8ZlHOZXbXK3/C0XfJgUCu6qAZcawv05ZhZzODGUerFBPsPUDQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2550,14 +2577,14 @@ } }, "node_modules/@smithy/middleware-content-length": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.0.5.tgz", - "integrity": "sha512-l1jlNZoYzoCC7p0zCtBDE5OBXZ95yMKlRlftooE5jPWQn4YBPLgsp+oeHp7iMHaTGoUdFqmHOPa8c9G3gBsRpQ==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.1.1.tgz", + "integrity": "sha512-9wlfBBgTsRvC2JxLJxv4xDGNBrZuio3AgSl0lSFX7fneW2cGskXTYpFxCdRYD2+5yzmsiTuaAJD1Wp7gWt9y9w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", + "@smithy/protocol-http": "^5.2.1", + "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, "engines": { @@ -2565,19 +2592,19 @@ } }, "node_modules/@smithy/middleware-endpoint": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.1.18.tgz", - "integrity": "sha512-ZhvqcVRPZxnZlokcPaTwb+r+h4yOIOCJmx0v2d1bpVlmP465g3qpVSf7wxcq5zZdu4jb0H4yIMxuPwDJSQc3MQ==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.2.4.tgz", + "integrity": "sha512-FZ4hzupOmthm8Q8ujYrd0I+/MHwVMuSTdkDtIQE0xVuvJt9pLT6Q+b0p4/t+slDyrpcf+Wj7SN+ZqT5OryaaZg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.8.0", - "@smithy/middleware-serde": "^4.0.9", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", - "@smithy/url-parser": "^4.0.5", - "@smithy/util-middleware": "^4.0.5", + "@smithy/core": "^3.12.0", + "@smithy/middleware-serde": "^4.1.1", + "@smithy/node-config-provider": "^4.2.2", + "@smithy/shared-ini-file-loader": "^4.2.0", + "@smithy/types": "^4.5.0", + "@smithy/url-parser": "^4.1.1", + "@smithy/util-middleware": "^4.1.1", "tslib": "^2.6.2" }, "engines": { @@ -2585,36 +2612,35 @@ } }, "node_modules/@smithy/middleware-retry": { - "version": "4.1.19", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.1.19.tgz", - "integrity": "sha512-X58zx/NVECjeuUB6A8HBu4bhx72EoUz+T5jTMIyeNKx2lf+Gs9TmWPNNkH+5QF0COjpInP/xSpJGJ7xEnAklQQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.3.0.tgz", + "integrity": "sha512-qhEX9745fAxZvtLM4bQJAVC98elWjiMO2OiHl1s6p7hUzS4QfZO1gXUYNwEK8m0J6NoCD5W52ggWxbIDHI0XSg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.1.4", - "@smithy/protocol-http": "^5.1.3", - "@smithy/service-error-classification": "^4.0.7", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-retry": "^4.0.7", - "@types/uuid": "^9.0.1", - "tslib": "^2.6.2", - "uuid": "^9.0.1" + "@smithy/node-config-provider": "^4.2.2", + "@smithy/protocol-http": "^5.2.1", + "@smithy/service-error-classification": "^4.1.2", + "@smithy/smithy-client": "^4.6.4", + "@smithy/types": "^4.5.0", + "@smithy/util-middleware": "^4.1.1", + "@smithy/util-retry": "^4.1.2", + "@smithy/uuid": "^1.0.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/middleware-serde": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.0.9.tgz", - "integrity": "sha512-uAFFR4dpeoJPGz8x9mhxp+RPjo5wW0QEEIPPPbLXiRRWeCATf/Km3gKIVR5vaP8bN1kgsPhcEeh+IZvUlBv6Xg==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.1.1.tgz", + "integrity": "sha512-lh48uQdbCoj619kRouev5XbWhCwRKLmphAif16c4J6JgJ4uXjub1PI6RL38d3BLliUvSso6klyB/LTNpWSNIyg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", + "@smithy/protocol-http": "^5.2.1", + "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, "engines": { @@ -2622,13 +2648,13 @@ } }, "node_modules/@smithy/middleware-stack": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.0.5.tgz", - "integrity": "sha512-/yoHDXZPh3ocRVyeWQFvC44u8seu3eYzZRveCMfgMOBcNKnAmOvjbL9+Cp5XKSIi9iYA9PECUuW2teDAk8T+OQ==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.1.1.tgz", + "integrity": "sha512-ygRnniqNcDhHzs6QAPIdia26M7e7z9gpkIMUe/pK0RsrQ7i5MblwxY8078/QCnGq6AmlUUWgljK2HlelsKIb/A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.2", + "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, "engines": { @@ -2636,15 +2662,15 @@ } }, "node_modules/@smithy/node-config-provider": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.1.4.tgz", - "integrity": "sha512-+UDQV/k42jLEPPHSn39l0Bmc4sB1xtdI9Gd47fzo/0PbXzJ7ylgaOByVjF5EeQIumkepnrJyfx86dPa9p47Y+w==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.2.2.tgz", + "integrity": "sha512-SYGTKyPvyCfEzIN5rD8q/bYaOPZprYUPD2f5g9M7OjaYupWOoQFYJ5ho+0wvxIRf471i2SR4GoiZ2r94Jq9h6A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", + "@smithy/property-provider": "^4.1.1", + "@smithy/shared-ini-file-loader": "^4.2.0", + "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, "engines": { @@ -2652,16 +2678,16 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.1.1.tgz", - "integrity": "sha512-RHnlHqFpoVdjSPPiYy/t40Zovf3BBHc2oemgD7VsVTFFZrU5erFFe0n52OANZZ/5sbshgD93sOh5r6I35Xmpaw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.2.1.tgz", + "integrity": "sha512-REyybygHlxo3TJICPF89N2pMQSf+p+tBJqpVe1+77Cfi9HBPReNjTgtZ1Vg73exq24vkqJskKDpfF74reXjxfw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.0.5", - "@smithy/protocol-http": "^5.1.3", - "@smithy/querystring-builder": "^4.0.5", - "@smithy/types": "^4.3.2", + "@smithy/abort-controller": "^4.1.1", + "@smithy/protocol-http": "^5.2.1", + "@smithy/querystring-builder": "^4.1.1", + "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, "engines": { @@ -2669,13 +2695,13 @@ } }, "node_modules/@smithy/property-provider": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.0.5.tgz", - "integrity": "sha512-R/bswf59T/n9ZgfgUICAZoWYKBHcsVDurAGX88zsiUtOTA/xUAPyiT+qkNCPwFn43pZqN84M4MiUsbSGQmgFIQ==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.1.1.tgz", + "integrity": "sha512-gm3ZS7DHxUbzC2wr8MUCsAabyiXY0gaj3ROWnhSx/9sPMc6eYLMM4rX81w1zsMaObj2Lq3PZtNCC1J6lpEY7zg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.2", + "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, "engines": { @@ -2683,13 +2709,13 @@ } }, "node_modules/@smithy/protocol-http": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.1.3.tgz", - "integrity": "sha512-fCJd2ZR7D22XhDY0l+92pUag/7je2BztPRQ01gU5bMChcyI0rlly7QFibnYHzcxDvccMjlpM/Q1ev8ceRIb48w==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.2.1.tgz", + "integrity": "sha512-T8SlkLYCwfT/6m33SIU/JOVGNwoelkrvGjFKDSDtVvAXj/9gOT78JVJEas5a+ETjOu4SVvpCstKgd0PxSu/aHw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.2", + "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, "engines": { @@ -2697,14 +2723,14 @@ } }, "node_modules/@smithy/querystring-builder": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.0.5.tgz", - "integrity": "sha512-NJeSCU57piZ56c+/wY+AbAw6rxCCAOZLCIniRE7wqvndqxcKKDOXzwWjrY7wGKEISfhL9gBbAaWWgHsUGedk+A==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.1.1.tgz", + "integrity": "sha512-J9b55bfimP4z/Jg1gNo+AT84hr90p716/nvxDkPGCD4W70MPms0h8KF50RDRgBGZeL83/u59DWNqJv6tEP/DHA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.2", - "@smithy/util-uri-escape": "^4.0.0", + "@smithy/types": "^4.5.0", + "@smithy/util-uri-escape": "^4.1.0", "tslib": "^2.6.2" }, "engines": { @@ -2712,13 +2738,13 @@ } }, "node_modules/@smithy/querystring-parser": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.0.5.tgz", - "integrity": "sha512-6SV7md2CzNG/WUeTjVe6Dj8noH32r4MnUeFKZrnVYsQxpGSIcphAanQMayi8jJLZAWm6pdM9ZXvKCpWOsIGg0w==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.1.1.tgz", + "integrity": "sha512-63TEp92YFz0oQ7Pj9IuI3IgnprP92LrZtRAkE3c6wLWJxfy/yOPRt39IOKerVr0JS770olzl0kGafXlAXZ1vng==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.2", + "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, "engines": { @@ -2726,26 +2752,26 @@ } }, "node_modules/@smithy/service-error-classification": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.0.7.tgz", - "integrity": "sha512-XvRHOipqpwNhEjDf2L5gJowZEm5nsxC16pAZOeEcsygdjv9A2jdOh3YoDQvOXBGTsaJk6mNWtzWalOB9976Wlg==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.1.2.tgz", + "integrity": "sha512-Kqd8wyfmBWHZNppZSMfrQFpc3M9Y/kjyN8n8P4DqJJtuwgK1H914R471HTw7+RL+T7+kI1f1gOnL7Vb5z9+NgQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.2" + "@smithy/types": "^4.5.0" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.0.5.tgz", - "integrity": "sha512-YVVwehRDuehgoXdEL4r1tAAzdaDgaC9EQvhK0lEbfnbrd0bd5+CTQumbdPryX3J2shT7ZqQE+jPW4lmNBAB8JQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.2.0.tgz", + "integrity": "sha512-OQTfmIEp2LLuWdxa8nEEPhZmiOREO6bcB6pjs0AySf4yiZhl6kMOfqmcwcY8BaBPX+0Tb+tG7/Ia/6mwpoZ7Pw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.2", + "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, "engines": { @@ -2753,19 +2779,19 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.1.3.tgz", - "integrity": "sha512-mARDSXSEgllNzMw6N+mC+r1AQlEBO3meEAkR/UlfAgnMzJUB3goRBWgip1EAMG99wh36MDqzo86SfIX5Y+VEaw==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.2.1.tgz", + "integrity": "sha512-M9rZhWQLjlQVCCR37cSjHfhriGRN+FQ8UfgrYNufv66TJgk+acaggShl3KS5U/ssxivvZLlnj7QH2CUOKlxPyA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.0.0", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", - "@smithy/util-hex-encoding": "^4.0.0", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-uri-escape": "^4.0.0", - "@smithy/util-utf8": "^4.0.0", + "@smithy/is-array-buffer": "^4.1.0", + "@smithy/protocol-http": "^5.2.1", + "@smithy/types": "^4.5.0", + "@smithy/util-hex-encoding": "^4.1.0", + "@smithy/util-middleware": "^4.1.1", + "@smithy/util-uri-escape": "^4.1.0", + "@smithy/util-utf8": "^4.1.0", "tslib": "^2.6.2" }, "engines": { @@ -2773,18 +2799,18 @@ } }, "node_modules/@smithy/smithy-client": { - "version": "4.4.10", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.4.10.tgz", - "integrity": "sha512-iW6HjXqN0oPtRS0NK/zzZ4zZeGESIFcxj2FkWed3mcK8jdSdHzvnCKXSjvewESKAgGKAbJRA+OsaqKhkdYRbQQ==", + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.6.4.tgz", + "integrity": "sha512-qL7O3VDyfzCSN9r+sdbQXGhaHtrfSJL30En6Jboj0I3bobf2g1/T0eP2L4qxqrEW26gWhJ4THI4ElVVLjYyBHg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.8.0", - "@smithy/middleware-endpoint": "^4.1.18", - "@smithy/middleware-stack": "^4.0.5", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", - "@smithy/util-stream": "^4.2.4", + "@smithy/core": "^3.12.0", + "@smithy/middleware-endpoint": "^4.2.4", + "@smithy/middleware-stack": "^4.1.1", + "@smithy/protocol-http": "^5.2.1", + "@smithy/types": "^4.5.0", + "@smithy/util-stream": "^4.3.2", "tslib": "^2.6.2" }, "engines": { @@ -2792,9 +2818,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.3.2.tgz", - "integrity": "sha512-QO4zghLxiQ5W9UZmX2Lo0nta2PuE1sSrXUYDoaB6HMR762C0P7v/HEPHf6ZdglTVssJG1bsrSBxdc3quvDSihw==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.5.0.tgz", + "integrity": "sha512-RkUpIOsVlAwUIZXO1dsz8Zm+N72LClFfsNqf173catVlvRZiwPy0x2u0JLEA4byreOPKDZPGjmPDylMoP8ZJRg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2805,14 +2831,14 @@ } }, "node_modules/@smithy/url-parser": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.0.5.tgz", - "integrity": "sha512-j+733Um7f1/DXjYhCbvNXABV53NyCRRA54C7bNEIxNPs0YjfRxeMKjjgm2jvTYrciZyCjsicHwQ6Q0ylo+NAUw==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.1.1.tgz", + "integrity": "sha512-bx32FUpkhcaKlEoOMbScvc93isaSiRM75pQ5IgIBaMkT7qMlIibpPRONyx/0CvrXHzJLpOn/u6YiDX2hcvs7Dg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/querystring-parser": "^4.0.5", - "@smithy/types": "^4.3.2", + "@smithy/querystring-parser": "^4.1.1", + "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, "engines": { @@ -2820,14 +2846,14 @@ } }, "node_modules/@smithy/util-base64": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.0.0.tgz", - "integrity": "sha512-CvHfCmO2mchox9kjrtzoHkWHxjHZzaFojLc8quxXY7WAAMAg43nuxwv95tATVgQFNDwd4M9S1qFzj40Ul41Kmg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.1.0.tgz", + "integrity": "sha512-RUGd4wNb8GeW7xk+AY5ghGnIwM96V0l2uzvs/uVHf+tIuVX2WSvynk5CxNoBCsM2rQRSZElAo9rt3G5mJ/gktQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^4.0.0", - "@smithy/util-utf8": "^4.0.0", + "@smithy/util-buffer-from": "^4.1.0", + "@smithy/util-utf8": "^4.1.0", "tslib": "^2.6.2" }, "engines": { @@ -2835,9 +2861,9 @@ } }, "node_modules/@smithy/util-body-length-browser": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.0.0.tgz", - "integrity": "sha512-sNi3DL0/k64/LO3A256M+m3CDdG6V7WKWHdAiBBMUN8S3hK3aMPhwnPik2A/a2ONN+9doY9UxaLfgqsIRg69QA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.1.0.tgz", + "integrity": "sha512-V2E2Iez+bo6bUMOTENPr6eEmepdY8Hbs+Uc1vkDKgKNA/brTJqOW/ai3JO1BGj9GbCeLqw90pbbH7HFQyFotGQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2848,9 +2874,9 @@ } }, "node_modules/@smithy/util-body-length-node": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.0.0.tgz", - "integrity": "sha512-q0iDP3VsZzqJyje8xJWEJCNIu3lktUGVoSy1KB0UWym2CL1siV3artm+u1DFYTLejpsrdGyCSWBdGNjJzfDPjg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.1.0.tgz", + "integrity": "sha512-BOI5dYjheZdgR9XiEM3HJcEMCXSoqbzu7CzIgYrx0UtmvtC3tC2iDGpJLsSRFffUpy8ymsg2ARMP5fR8mtuUQQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2861,13 +2887,13 @@ } }, "node_modules/@smithy/util-buffer-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.0.0.tgz", - "integrity": "sha512-9TOQ7781sZvddgO8nxueKi3+yGvkY35kotA0Y6BWRajAv8jjmigQ1sBwz0UX47pQMYXJPahSKEKYFgt+rXdcug==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.1.0.tgz", + "integrity": "sha512-N6yXcjfe/E+xKEccWEKzK6M+crMrlwaCepKja0pNnlSkm6SjAeLKKA++er5Ba0I17gvKfN/ThV+ZOx/CntKTVw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.0.0", + "@smithy/is-array-buffer": "^4.1.0", "tslib": "^2.6.2" }, "engines": { @@ -2875,9 +2901,9 @@ } }, "node_modules/@smithy/util-config-provider": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.0.0.tgz", - "integrity": "sha512-L1RBVzLyfE8OXH+1hsJ8p+acNUSirQnWQ6/EgpchV88G6zGBTDPdXiiExei6Z1wR2RxYvxY/XLw6AMNCCt8H3w==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.1.0.tgz", + "integrity": "sha512-swXz2vMjrP1ZusZWVTB/ai5gK+J8U0BWvP10v9fpcFvg+Xi/87LHvHfst2IgCs1i0v4qFZfGwCmeD/KNCdJZbQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2888,15 +2914,15 @@ } }, "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.0.26", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.0.26.tgz", - "integrity": "sha512-xgl75aHIS/3rrGp7iTxQAOELYeyiwBu+eEgAk4xfKwJJ0L8VUjhO2shsDpeil54BOFsqmk5xfdesiewbUY5tKQ==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.1.4.tgz", + "integrity": "sha512-mLDJ1s4eA3vwOGaQOEPlg5LB4LdZUUMpB5UMOMofeGhWqiS7WR7dTpLiNi9zVn+YziKUd3Af5NLfxDs7NJqmIw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.0.5", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", + "@smithy/property-provider": "^4.1.1", + "@smithy/smithy-client": "^4.6.4", + "@smithy/types": "^4.5.0", "bowser": "^2.11.0", "tslib": "^2.6.2" }, @@ -2905,18 +2931,18 @@ } }, "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.0.26", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.0.26.tgz", - "integrity": "sha512-z81yyIkGiLLYVDetKTUeCZQ8x20EEzvQjrqJtb/mXnevLq2+w3XCEWTJ2pMp401b6BkEkHVfXb/cROBpVauLMQ==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.1.4.tgz", + "integrity": "sha512-pjX2iMTcOASaSanAd7bu6i3fcMMezr3NTr8Rh64etB0uHRZi+Aw86DoCxPESjY4UTIuA06hhqtTtw95o//imYA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/config-resolver": "^4.1.5", - "@smithy/credential-provider-imds": "^4.0.7", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/property-provider": "^4.0.5", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", + "@smithy/config-resolver": "^4.2.2", + "@smithy/credential-provider-imds": "^4.1.2", + "@smithy/node-config-provider": "^4.2.2", + "@smithy/property-provider": "^4.1.1", + "@smithy/smithy-client": "^4.6.4", + "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, "engines": { @@ -2924,14 +2950,14 @@ } }, "node_modules/@smithy/util-endpoints": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.0.7.tgz", - "integrity": "sha512-klGBP+RpBp6V5JbrY2C/VKnHXn3d5V2YrifZbmMY8os7M6m8wdYFoO6w/fe5VkP+YVwrEktW3IWYaSQVNZJ8oQ==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.1.2.tgz", + "integrity": "sha512-+AJsaaEGb5ySvf1SKMRrPZdYHRYSzMkCoK16jWnIMpREAnflVspMIDeCVSZJuj+5muZfgGpNpijE3mUNtjv01Q==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.1.4", - "@smithy/types": "^4.3.2", + "@smithy/node-config-provider": "^4.2.2", + "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, "engines": { @@ -2939,9 +2965,9 @@ } }, "node_modules/@smithy/util-hex-encoding": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.0.0.tgz", - "integrity": "sha512-Yk5mLhHtfIgW2W2WQZWSg5kuMZCVbvhFmC7rV4IO2QqnZdbEFPmQnCcGMAX2z/8Qj3B9hYYNjZOhWym+RwhePw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.1.0.tgz", + "integrity": "sha512-1LcueNN5GYC4tr8mo14yVYbh/Ur8jHhWOxniZXii+1+ePiIbsLZ5fEI0QQGtbRRP5mOhmooos+rLmVASGGoq5w==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2952,13 +2978,13 @@ } }, "node_modules/@smithy/util-middleware": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.0.5.tgz", - "integrity": "sha512-N40PfqsZHRSsByGB81HhSo+uvMxEHT+9e255S53pfBw/wI6WKDI7Jw9oyu5tJTLwZzV5DsMha3ji8jk9dsHmQQ==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.1.1.tgz", + "integrity": "sha512-CGmZ72mL29VMfESz7S6dekqzCh8ZISj3B+w0g1hZFXaOjGTVaSqfAEFAq8EGp8fUL+Q2l8aqNmt8U1tglTikeg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.2", + "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, "engines": { @@ -2966,14 +2992,14 @@ } }, "node_modules/@smithy/util-retry": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.0.7.tgz", - "integrity": "sha512-TTO6rt0ppK70alZpkjwy+3nQlTiqNfoXja+qwuAchIEAIoSZW8Qyd76dvBv3I5bCpE38APafG23Y/u270NspiQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.1.2.tgz", + "integrity": "sha512-NCgr1d0/EdeP6U5PSZ9Uv5SMR5XRRYoVr1kRVtKZxWL3tixEL3UatrPIMFZSKwHlCcp2zPLDvMubVDULRqeunA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/service-error-classification": "^4.0.7", - "@smithy/types": "^4.3.2", + "@smithy/service-error-classification": "^4.1.2", + "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, "engines": { @@ -2981,19 +3007,19 @@ } }, "node_modules/@smithy/util-stream": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.2.4.tgz", - "integrity": "sha512-vSKnvNZX2BXzl0U2RgCLOwWaAP9x/ddd/XobPK02pCbzRm5s55M53uwb1rl/Ts7RXZvdJZerPkA+en2FDghLuQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.3.2.tgz", + "integrity": "sha512-Ka+FA2UCC/Q1dEqUanCdpqwxOFdf5Dg2VXtPtB1qxLcSGh5C1HdzklIt18xL504Wiy9nNUKwDMRTVCbKGoK69g==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/fetch-http-handler": "^5.1.1", - "@smithy/node-http-handler": "^4.1.1", - "@smithy/types": "^4.3.2", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-buffer-from": "^4.0.0", - "@smithy/util-hex-encoding": "^4.0.0", - "@smithy/util-utf8": "^4.0.0", + "@smithy/fetch-http-handler": "^5.2.1", + "@smithy/node-http-handler": "^4.2.1", + "@smithy/types": "^4.5.0", + "@smithy/util-base64": "^4.1.0", + "@smithy/util-buffer-from": "^4.1.0", + "@smithy/util-hex-encoding": "^4.1.0", + "@smithy/util-utf8": "^4.1.0", "tslib": "^2.6.2" }, "engines": { @@ -3001,9 +3027,9 @@ } }, "node_modules/@smithy/util-uri-escape": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.0.0.tgz", - "integrity": "sha512-77yfbCbQMtgtTylO9itEAdpPXSog3ZxMe09AEhm0dU0NLTalV70ghDZFR+Nfi1C60jnJoh/Re4090/DuZh2Omg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.1.0.tgz", + "integrity": "sha512-b0EFQkq35K5NHUYxU72JuoheM6+pytEVUGlTwiFxWFpmddA+Bpz3LgsPRIpBk8lnPE47yT7AF2Egc3jVnKLuPg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -3014,13 +3040,13 @@ } }, "node_modules/@smithy/util-utf8": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.0.0.tgz", - "integrity": "sha512-b+zebfKCfRdgNJDknHCob3O7FpeYQN6ZG6YLExMcasDHsCXlsXCEuiPZeLnJLpwa5dvPetGlnGCiMHuLwGvFow==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.1.0.tgz", + "integrity": "sha512-mEu1/UIXAdNYuBcyEPbjScKi/+MQVXNIuY/7Cm5XLIWe319kDrT5SizBE95jqtmEXoDbGoZxKLCMttdZdqTZKQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^4.0.0", + "@smithy/util-buffer-from": "^4.1.0", "tslib": "^2.6.2" }, "engines": { @@ -3028,20 +3054,40 @@ } }, "node_modules/@smithy/util-waiter": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.0.7.tgz", - "integrity": "sha512-mYqtQXPmrwvUljaHyGxYUIIRI3qjBTEb/f5QFi3A6VlxhpmZd5mWXn9W+qUkf2pVE1Hv3SqxefiZOPGdxmO64A==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.1.1.tgz", + "integrity": "sha512-PJBmyayrlfxM7nbqjomF4YcT1sApQwZio0NHSsT0EzhJqljRmvhzqZua43TyEs80nJk2Cn2FGPg/N8phH6KeCQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.0.5", - "@smithy/types": "^4.3.2", + "@smithy/abort-controller": "^4.1.1", + "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, + "node_modules/@smithy/uuid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.0.0.tgz", + "integrity": "sha512-OlA/yZHh0ekYFnbUkmYBDQPE6fGfdrvgz39ktp8Xf+FA6BfxLejPTMDOG0Nfk5/rDySAz1dRbFf24zaAFYVXlQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@tediousjs/connection-string": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@tediousjs/connection-string/-/connection-string-0.5.0.tgz", @@ -3350,9 +3396,9 @@ } }, "node_modules/@types/node": { - "version": "20.19.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.11.tgz", - "integrity": "sha512-uug3FEEGv0r+jrecvUUpbY8lLisvIjg6AAic6a2bSP5OEOLeJsDSnvhCDov7ipFFMXS3orMpzlmi0ZcuGkBbow==", + "version": "20.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz", + "integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -3366,9 +3412,9 @@ "license": "MIT" }, "node_modules/@types/nodemailer": { - "version": "6.4.18", - "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.18.tgz", - "integrity": "sha512-K+OGGXYCxIGkZ59EzoEFkKDkxUT2yQ4f5zgLb+bOJ+pPTZd8M2i/DGMVYrRigUwFnL76URW5VMqMCkgHgjLX0w==", + "version": "6.4.19", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.19.tgz", + "integrity": "sha512-Fi8DwmuAduTk1/1MpkR9EwS0SsDvYXx5RxivAVII1InDCIxmhj/iQm3W8S3EVb/0arnblr6PK0FK4wYa7bwdLg==", "dev": true, "license": "MIT", "dependencies": { @@ -3432,9 +3478,9 @@ } }, "node_modules/@types/semver": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.0.tgz", - "integrity": "sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", "dev": true, "license": "MIT" }, @@ -3498,13 +3544,6 @@ "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", "license": "MIT" }, - "node_modules/@types/uuid": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", - "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", @@ -3971,9 +4010,9 @@ } }, "node_modules/axios": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", - "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -4134,6 +4173,16 @@ ], "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.7.tgz", + "integrity": "sha512-bxxN2M3a4d1CRoQC//IqsR5XrLh0IJ8TCv2x6Y9N0nckNz/rTjZB3//GGscZziZOxmjP55rzxg/ze7usFI9FqQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, "node_modules/bcryptjs": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", @@ -4230,9 +4279,9 @@ "license": "MIT" }, "node_modules/bowser": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.12.0.tgz", - "integrity": "sha512-HcOcTudTeEWgbHh0Y1Tyb6fdeR71m4b/QACf0D4KswGTsNeIJQmg38mRENZPAYPZvGFN3fk3604XbQEPdxXdKg==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.12.1.tgz", + "integrity": "sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==", "dev": true, "license": "MIT" }, @@ -4260,9 +4309,9 @@ } }, "node_modules/browserslist": { - "version": "4.25.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.3.tgz", - "integrity": "sha512-cDGv1kkDI4/0e5yON9yM5G/0A5u8sf5TnmdX5C9qHzI9PPu++sQ9zjm1k9NiOrf3riY4OkK0zSGqfvJyJsgCBQ==", + "version": "4.26.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz", + "integrity": "sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==", "dev": true, "funding": [ { @@ -4280,9 +4329,10 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001735", - "electron-to-chromium": "^1.5.204", - "node-releases": "^2.0.19", + "baseline-browser-mapping": "^2.8.3", + "caniuse-lite": "^1.0.30001741", + "electron-to-chromium": "^1.5.218", + "node-releases": "^2.0.21", "update-browserslist-db": "^1.1.3" }, "bin": { @@ -4386,6 +4436,65 @@ "node": ">= 0.8" } }, + "node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/c12/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/c12/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -4436,9 +4545,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001735", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001735.tgz", - "integrity": "sha512-EV/laoX7Wq2J9TQlyIXRxTJqIw4sxfXS4OYgudGxBYRuTv0q7AM6yMEpU/Vo1I94thg9U6EZ2NfZx9GJq83u7w==", + "version": "1.0.30001745", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001745.tgz", + "integrity": "sha512-ywt6i8FzvdgrrrGbr1jZVObnVv6adj+0if2/omv9cmR2oiZs30zL4DIyaptKcbOrBdOIc74QTMoJvSE2QHh5UQ==", "dev": true, "funding": [ { @@ -4537,6 +4646,16 @@ "node": ">=8" } }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, "node_modules/cjs-module-lexer": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", @@ -4748,6 +4867,23 @@ "typedarray": "^0.0.6" } }, + "node_modules/confbox": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", + "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -4862,9 +4998,9 @@ } }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4879,9 +5015,9 @@ } }, "node_modules/dedent": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz", - "integrity": "sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", + "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -4910,6 +5046,16 @@ "node": ">=0.10.0" } }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/default-browser": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", @@ -4950,6 +5096,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -4977,6 +5130,13 @@ "node": ">= 0.8" } }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -5154,10 +5314,21 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/effect": { + "version": "3.16.12", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.16.12.tgz", + "integrity": "sha512-N39iBk0K71F9nb442TLbTkjl24FLUzuvx2i1I2RsEAQsdAdUTuUoW0vlfUXgkMTUOnYqKnWcFfqw4hK4Pw27hg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, "node_modules/electron-to-chromium": { - "version": "1.5.207", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.207.tgz", - "integrity": "sha512-mryFrrL/GXDTmAtIVMVf+eIXM09BBPlO5IQ7lUyKmK8d+A4VpRGG+M3ofoVef6qyF8s60rJei8ymlJxjUA8Faw==", + "version": "1.5.224", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.224.tgz", + "integrity": "sha512-kWAoUu/bwzvnhpdZSIc6KUyvkI1rbRXMT0Eq8pKReyOyaPZcctMli+EgvcN1PAvwVc7Tdo4Fxi2PsLNDU05mdg==", "dev": true, "license": "ISC" }, @@ -5181,6 +5352,16 @@ "dev": true, "license": "MIT" }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/enabled": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", @@ -5210,9 +5391,9 @@ } }, "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5635,6 +5816,36 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/exsolve": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", + "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5923,6 +6134,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -6040,6 +6252,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -7276,6 +7506,16 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jiti": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.0.tgz", + "integrity": "sha512-VXe6RjJkBPj0ohtqaO8vSWP3ZhAKo66fKrFNCll4BTcwljPLz03pCbaNKfzGP5MbrCYcbJ7v0nOYYwUzTEIdXQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/joi": { "version": "17.13.3", "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", @@ -7824,9 +8064,9 @@ } }, "node_modules/mysql2": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.0.tgz", - "integrity": "sha512-tT6pomf5Z/I7Jzxu8sScgrYBMK9bUFWd7Kbo6Fs1L0M13OOIJ/ZobGKS3Z7tQ8Re4lj+LnLXIQVZZxa3fhYKzA==", + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.1.tgz", + "integrity": "sha512-WZMIRZstT2MFfouEaDz/AGFnGi1A2GwaDe7XvKTdRJEYiAHbOrh4S3d8KFmQeh11U85G+BFjIvS1Di5alusZsw==", "license": "MIT", "dependencies": { "aws-ssl-profiles": "^1.1.1", @@ -7918,6 +8158,13 @@ "node": ">=6.0.0" } }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "devOptional": true, + "license": "MIT" + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -7926,9 +8173,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", + "integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==", "dev": true, "license": "MIT" }, @@ -8040,6 +8287,26 @@ "node": ">=8" } }, + "node_modules/nypm": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", + "integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.2", + "pathe": "^2.0.3", + "pkg-types": "^2.3.0", + "tinyexec": "^1.0.1" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": "^14.16.0 || >=16.10.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -8061,6 +8328,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -8299,6 +8573,20 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/pg": { "version": "8.16.3", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", @@ -8487,6 +8775,18 @@ "node": ">=8" } }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -8581,22 +8881,29 @@ } }, "node_modules/prisma": { - "version": "5.22.0", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", - "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", + "version": "6.16.2", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.16.2.tgz", + "integrity": "sha512-aRvldGE5UUJTtVmFiH3WfNFNiqFlAtePUxcI0UEGlnXCX7DqhiMT5TRYwncHFeA/Reca5W6ToXXyCMTeFPdSXA==", + "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/engines": "5.22.0" + "@prisma/config": "6.16.2", + "@prisma/engines": "6.16.2" }, "bin": { "prisma": "build/index.js" }, "engines": { - "node": ">=16.13" + "node": ">=18.18" }, - "optionalDependencies": { - "fsevents": "2.3.3" + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/process": { @@ -8668,7 +8975,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "individual", @@ -8741,6 +9048,17 @@ "node": ">= 0.8" } }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -9167,18 +9485,18 @@ "license": "ISC" }, "node_modules/simple-swizzle": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", - "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", "license": "MIT", "dependencies": { "is-arrayish": "^0.3.1" } }, "node_modules/simple-swizzle/node_modules/is-arrayish": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", "license": "MIT" }, "node_modules/simple-update-notifier": { @@ -9242,10 +9560,9 @@ } }, "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "license": "BSD-3-Clause" }, "node_modules/sqlstring": { @@ -9528,12 +9845,6 @@ "node": ">=0.10.0" } }, - "node_modules/tedious/node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "license": "BSD-3-Clause" - }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -9586,6 +9897,13 @@ "dev": true, "license": "MIT" }, + "node_modules/tinyexec": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz", + "integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==", + "devOptional": true, + "license": "MIT" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -9648,9 +9966,9 @@ } }, "node_modules/ts-jest": { - "version": "29.4.1", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.1.tgz", - "integrity": "sha512-SaeUtjfpg9Uqu8IbeDKtdaS0g8lS6FT6OzM3ezrDfErPJPHNDo/Ey+VFGP1bQIDfagYDLyRpd7O15XpG1Es2Uw==", + "version": "29.4.4", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.4.tgz", + "integrity": "sha512-ccVcRABct5ZELCT5U0+DZwkXMCcOCLi2doHRrKy1nK/s7J7bch6TzJMsrY09WxgUUIP/ITfmcDS8D2yl63rnXw==", "dev": true, "license": "MIT", "dependencies": { @@ -9822,7 +10140,7 @@ "version": "5.9.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -9925,14 +10243,9 @@ } }, "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "dev": true, - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "license": "MIT", "bin": { "uuid": "dist/bin/uuid" diff --git a/backend-node/package.json b/backend-node/package.json index 8cfa8cf7..2caf0d1c 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -27,7 +27,7 @@ "author": "", "license": "ISC", "dependencies": { - "@prisma/client": "^5.7.1", + "@prisma/client": "^6.16.2", "@types/mssql": "^9.1.8", "axios": "^1.11.0", "bcryptjs": "^2.4.3", @@ -46,7 +46,6 @@ "nodemailer": "^6.9.7", "oracledb": "^6.9.0", "pg": "^8.16.3", - "prisma": "^5.7.1", "redis": "^4.6.10", "winston": "^3.11.0" }, @@ -73,6 +72,7 @@ "jest": "^29.7.0", "nodemon": "^3.1.10", "prettier": "^3.1.0", + "prisma": "^6.16.2", "supertest": "^6.3.3", "ts-jest": "^29.1.1", "ts-node": "^10.9.2", diff --git a/backend-node/prisma/schema.prisma b/backend-node/prisma/schema.prisma index d9dfa8ad..3e8812ab 100644 --- a/backend-node/prisma/schema.prisma +++ b/backend-node/prisma/schema.prisma @@ -50,27 +50,27 @@ model db_type_categories { } model external_db_connections { - id Int @id @default(autoincrement()) - connection_name String @db.VarChar(100) + id Int @id @default(autoincrement()) + connection_name String @db.VarChar(100) description String? - db_type String @db.VarChar(20) - host String @db.VarChar(255) + db_type String @db.VarChar(20) + host String @db.VarChar(255) port Int - database_name String @db.VarChar(100) - username String @db.VarChar(100) + database_name String @db.VarChar(100) + username String @db.VarChar(100) password String - connection_timeout Int? @default(30) - query_timeout Int? @default(60) - max_connections Int? @default(10) - ssl_enabled String? @default("N") @db.Char(1) - ssl_cert_path String? @db.VarChar(500) + connection_timeout Int? @default(30) + query_timeout Int? @default(60) + max_connections Int? @default(10) + ssl_enabled String? @default("N") @db.Char(1) + ssl_cert_path String? @db.VarChar(500) connection_options Json? - company_code String? @default("*") @db.VarChar(20) - is_active String? @default("Y") @db.Char(1) - created_date DateTime? @default(now()) @db.Timestamp(6) - created_by String? @db.VarChar(50) - updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6) - updated_by String? @db.VarChar(50) + company_code String? @default("*") @db.VarChar(20) + is_active String? @default("Y") @db.Char(1) + created_date DateTime? @default(now()) @db.Timestamp(6) + created_by String? @db.VarChar(50) + updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6) + updated_by String? @db.VarChar(50) // 관계 db_type_category db_type_categories? @relation(fields: [db_type], references: [type_code]) @@ -80,6 +80,83 @@ model external_db_connections { @@index([db_type], map: "idx_external_db_connections_db_type") } +model batch_configs { + id Int @id @default(autoincrement()) + batch_name String @db.VarChar(100) + description String? + cron_schedule String @db.VarChar(50) + is_active String? @default("Y") @db.Char(1) + company_code String? @default("*") @db.VarChar(20) + created_date DateTime? @default(now()) @db.Timestamp(6) + created_by String? @db.VarChar(50) + updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6) + updated_by String? @db.VarChar(50) + + // 관계 설정 + batch_mappings batch_mappings[] + execution_logs batch_execution_logs[] + + @@index([batch_name], map: "idx_batch_configs_name") + @@index([is_active], map: "idx_batch_configs_active") +} + +model batch_mappings { + id Int @id @default(autoincrement()) + batch_config_id Int + from_connection_type String @db.VarChar(20) + from_connection_id Int? + from_table_name String @db.VarChar(100) + from_column_name String @db.VarChar(100) + from_column_type String? @db.VarChar(50) + from_api_url String? @db.VarChar(500) + from_api_key String? @db.VarChar(200) + from_api_method String? @db.VarChar(10) + to_connection_type String @db.VarChar(20) + to_connection_id Int? + to_table_name String @db.VarChar(100) + to_column_name String @db.VarChar(100) + to_column_type String? @db.VarChar(50) + to_api_url String? @db.VarChar(500) + to_api_key String? @db.VarChar(200) + to_api_method String? @db.VarChar(10) + to_api_body String? @db.Text + mapping_order Int? @default(1) + created_date DateTime? @default(now()) @db.Timestamp(6) + created_by String? @db.VarChar(50) + + // 관계 설정 + batch_config batch_configs @relation(fields: [batch_config_id], references: [id], onDelete: Cascade) + + @@index([batch_config_id], map: "idx_batch_mappings_config") + @@index([from_connection_type, from_connection_id], map: "idx_batch_mappings_from") + @@index([to_connection_type, to_connection_id], map: "idx_batch_mappings_to") + @@index([from_connection_type, from_api_url], map: "idx_batch_mappings_from_api") + @@index([to_connection_type, to_api_url], map: "idx_batch_mappings_to_api") +} + +model batch_execution_logs { + id Int @id @default(autoincrement()) + batch_config_id Int + execution_status String @db.VarChar(20) + start_time DateTime @default(now()) @db.Timestamp(6) + end_time DateTime? @db.Timestamp(6) + duration_ms Int? + total_records Int? @default(0) + success_records Int? @default(0) + failed_records Int? @default(0) + error_message String? + error_details String? + server_name String? @db.VarChar(100) + process_id String? @db.VarChar(50) + + // 관계 설정 + batch_config batch_configs @relation(fields: [batch_config_id], references: [id], onDelete: Cascade) + + @@index([batch_config_id], map: "idx_batch_execution_logs_config") + @@index([execution_status], map: "idx_batch_execution_logs_status") + @@index([start_time], map: "idx_batch_execution_logs_start_time") +} + model admin_supply_mng { objid Decimal @id @default(0) @db.Decimal supply_code String? @default("NULL::character varying") @db.VarChar(100) diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index e58690bc..14093f09 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -20,7 +20,7 @@ import commonCodeRoutes from "./routes/commonCodeRoutes"; import dynamicFormRoutes from "./routes/dynamicFormRoutes"; import fileRoutes from "./routes/fileRoutes"; import companyManagementRoutes from "./routes/companyManagementRoutes"; -// import dataflowRoutes from "./routes/dataflowRoutes"; // 임시 주석 +import dataflowRoutes from "./routes/dataflowRoutes"; import dataflowDiagramRoutes from "./routes/dataflowDiagramRoutes"; import webTypeStandardRoutes from "./routes/webTypeStandardRoutes"; import buttonActionStandardRoutes from "./routes/buttonActionStandardRoutes"; @@ -33,9 +33,16 @@ import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes"; import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes"; import multiConnectionRoutes from "./routes/multiConnectionRoutes"; import screenFileRoutes from "./routes/screenFileRoutes"; -import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes"; +//import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes"; +import batchRoutes from "./routes/batchRoutes"; +import batchManagementRoutes from "./routes/batchManagementRoutes"; +import batchExecutionLogRoutes from "./routes/batchExecutionLogRoutes"; +// import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes"; // 파일이 존재하지 않음 import ddlRoutes from "./routes/ddlRoutes"; import entityReferenceRoutes from "./routes/entityReferenceRoutes"; +import externalCallRoutes from "./routes/externalCallRoutes"; +import externalCallConfigRoutes from "./routes/externalCallConfigRoutes"; +import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 // import userRoutes from './routes/userRoutes'; @@ -44,7 +51,14 @@ import entityReferenceRoutes from "./routes/entityReferenceRoutes"; const app = express(); // 기본 미들웨어 -app.use(helmet()); +app.use(helmet({ + contentSecurityPolicy: { + directives: { + ...helmet.contentSecurityPolicy.getDefaultDirectives(), + "frame-ancestors": ["'self'", "http://localhost:9771", "http://localhost:3000"], // 프론트엔드 도메인 허용 + }, + }, +})); app.use(compression()); app.use(express.json({ limit: "10mb" })); app.use(express.urlencoded({ extended: true, limit: "10mb" })); @@ -89,13 +103,20 @@ app.use( // Rate Limiting (개발 환경에서는 완화) const limiter = rateLimit({ windowMs: 1 * 60 * 1000, // 1분 - max: config.nodeEnv === "development" ? 1000 : 100, // 개발환경에서는 1000, 운영환경에서는 100 + max: config.nodeEnv === "development" ? 10000 : 1000, // 개발환경에서는 10000으로 증가, 운영환경에서는 100 message: { error: "너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.", }, skip: (req) => { - // 헬스 체크는 Rate Limiting 제외 - return req.path === "/health"; + // 헬스 체크와 자주 호출되는 API들은 Rate Limiting 완화 + return ( + req.path === "/health" || + req.path.includes("/table-management/") || + req.path.includes("/external-db-connections/") || + req.path.includes("/screen-management/") || + req.path.includes("/multi-connection/") || + req.path.includes("/dataflow-diagrams/") + ); }, }); app.use("/api/", limiter); @@ -121,7 +142,7 @@ app.use("/api/common-codes", commonCodeRoutes); app.use("/api/dynamic-form", dynamicFormRoutes); app.use("/api/files", fileRoutes); app.use("/api/company-management", companyManagementRoutes); -// app.use("/api/dataflow", dataflowRoutes); // 임시 주석 +app.use("/api/dataflow", dataflowRoutes); app.use("/api/dataflow-diagrams", dataflowDiagramRoutes); app.use("/api/admin/web-types", webTypeStandardRoutes); app.use("/api/admin/button-actions", buttonActionStandardRoutes); @@ -134,9 +155,14 @@ app.use("/api/test-button-dataflow", testButtonDataflowRoutes); app.use("/api/external-db-connections", externalDbConnectionRoutes); app.use("/api/multi-connection", multiConnectionRoutes); app.use("/api/screen-files", screenFileRoutes); -app.use("/api/db-type-categories", dbTypeCategoryRoutes); +app.use("/api/batch-configs", batchRoutes); +app.use("/api/batch-management", batchManagementRoutes); +app.use("/api/batch-execution-logs", batchExecutionLogRoutes); +// app.use("/api/db-type-categories", dbTypeCategoryRoutes); // 파일이 존재하지 않음 app.use("/api/ddl", ddlRoutes); app.use("/api/entity-reference", entityReferenceRoutes); +app.use("/api/external-calls", externalCallRoutes); +app.use("/api/external-call-configs", externalCallConfigRoutes); // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); @@ -158,11 +184,19 @@ app.use(errorHandler); const PORT = config.port; const HOST = config.host; -app.listen(PORT, HOST, () => { +app.listen(PORT, HOST, async () => { logger.info(`🚀 Server is running on ${HOST}:${PORT}`); logger.info(`📊 Environment: ${config.nodeEnv}`); logger.info(`🔗 Health check: http://${HOST}:${PORT}/health`); logger.info(`🌐 External access: http://39.117.244.52:${PORT}/health`); + + // 배치 스케줄러 초기화 + try { + await BatchSchedulerService.initialize(); + logger.info(`⏰ 배치 스케줄러가 시작되었습니다.`); + } catch (error) { + logger.error(`❌ 배치 스케줄러 초기화 실패:`, error); + } }); export default app; diff --git a/backend-node/src/config/database.ts b/backend-node/src/config/database.ts index 6dec398b..d3ecfd44 100644 --- a/backend-node/src/config/database.ts +++ b/backend-node/src/config/database.ts @@ -1,15 +1,20 @@ import { PrismaClient } from "@prisma/client"; import config from "./environment"; -// Prisma 클라이언트 인스턴스 생성 -const prisma = new PrismaClient({ - datasources: { - db: { - url: config.databaseUrl, +// Prisma 클라이언트 생성 함수 +function createPrismaClient() { + return new PrismaClient({ + datasources: { + db: { + url: config.databaseUrl, + }, }, - }, - log: config.debug ? ["query", "info", "warn", "error"] : ["error"], -}); + log: config.debug ? ["query", "info", "warn", "error"] : ["error"], + }); +} + +// 단일 인스턴스 생성 +const prisma = createPrismaClient(); // 데이터베이스 연결 테스트 async function testConnection() { @@ -41,4 +46,5 @@ if (config.nodeEnv === "development") { testConnection(); } -export default prisma; +// 기본 내보내기 +export = prisma; diff --git a/backend-node/src/config/environment.ts b/backend-node/src/config/environment.ts index 62fe0635..a4c6c33b 100644 --- a/backend-node/src/config/environment.ts +++ b/backend-node/src/config/environment.ts @@ -80,7 +80,7 @@ const getCorsOrigin = (): string[] | boolean => { const config: Config = { // 서버 설정 - port: parseInt(process.env.PORT || "3000", 10), + port: parseInt(process.env.PORT || "8080", 10), host: process.env.HOST || "0.0.0.0", nodeEnv: process.env.NODE_ENV || "development", diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index cc6b751e..e2e03e92 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -1523,7 +1523,7 @@ export const getUserInfo = async (req: AuthenticatedRequest, res: Response) => { partnerObjid: user.partner_objid, rank: user.rank, photo: user.photo - ? `data:image/jpeg;base64,${user.photo.toString("base64")}` + ? `data:image/jpeg;base64,${Buffer.from(user.photo).toString("base64")}` : null, locale: user.locale, companyCode: user.company_code, @@ -2415,7 +2415,7 @@ export const updateProfile = async ( const responseData = { ...updatedUser, photo: updatedUser?.photo - ? `data:image/jpeg;base64,${updatedUser.photo.toString("base64")}` + ? `data:image/jpeg;base64,${Buffer.from(updatedUser.photo).toString("base64")}` : null, }; diff --git a/backend-node/src/controllers/batchController.ts b/backend-node/src/controllers/batchController.ts index 99b66364..ba270f41 100644 --- a/backend-node/src/controllers/batchController.ts +++ b/backend-node/src/controllers/batchController.ts @@ -1,294 +1,281 @@ -// 배치 관리 컨트롤러 -// 작성일: 2024-12-23 +// 배치관리 컨트롤러 +// 작성일: 2024-12-24 -import { Request, Response } from 'express'; -import { BatchService } from '../services/batchService'; -import { BatchJob, BatchJobFilter } from '../types/batchManagement'; -import { AuthenticatedRequest } from '../middleware/authMiddleware'; +import { Request, Response } from "express"; +import { BatchService } from "../services/batchService"; +import { BatchConfigFilter, CreateBatchConfigRequest, UpdateBatchConfigRequest } from "../types/batchTypes"; + +export interface AuthenticatedRequest extends Request { + user?: { + userId: string; + username: string; + companyCode: string; + }; +} export class BatchController { /** - * 배치 작업 목록 조회 + * 배치 설정 목록 조회 + * GET /api/batch-configs */ - static async getBatchJobs(req: AuthenticatedRequest, res: Response): Promise { + static async getBatchConfigs(req: AuthenticatedRequest, res: Response) { try { - const filter: BatchJobFilter = { - job_name: req.query.job_name as string, - job_type: req.query.job_type as string, - is_active: req.query.is_active as string, - company_code: req.user?.companyCode || '*', - search: req.query.search as string, + const { page = 1, limit = 10, search, isActive } = req.query; + + const filter: BatchConfigFilter = { + page: Number(page), + limit: Number(limit), + search: search as string, + is_active: isActive as string }; - const jobs = await BatchService.getBatchJobs(filter); - - res.status(200).json({ + const result = await BatchService.getBatchConfigs(filter); + + res.json({ success: true, - data: jobs, - message: '배치 작업 목록을 조회했습니다.', + data: result.data, + pagination: result.pagination }); } catch (error) { - console.error('배치 작업 목록 조회 오류:', error); + console.error("배치 설정 목록 조회 오류:", error); res.status(500).json({ success: false, - message: error instanceof Error ? error.message : '배치 작업 목록 조회에 실패했습니다.', + message: "배치 설정 목록 조회에 실패했습니다." }); } } /** - * 배치 작업 상세 조회 + * 사용 가능한 커넥션 목록 조회 + * GET /api/batch-configs/connections */ - static async getBatchJobById(req: AuthenticatedRequest, res: Response): Promise { + static async getAvailableConnections(req: AuthenticatedRequest, res: Response) { try { - const id = parseInt(req.params.id); - if (isNaN(id)) { - res.status(400).json({ + const result = await BatchService.getAvailableConnections(); + + if (result.success) { + res.json(result); + } else { + res.status(500).json(result); + } + } catch (error) { + console.error("커넥션 목록 조회 오류:", error); + res.status(500).json({ + success: false, + message: "커넥션 목록 조회에 실패했습니다." + }); + } + } + + /** + * 테이블 목록 조회 (내부/외부 DB) + * GET /api/batch-configs/connections/:type/tables + * GET /api/batch-configs/connections/:type/:id/tables + */ + static async getTablesFromConnection(req: AuthenticatedRequest, res: Response) { + try { + const { type, id } = req.params; + + if (!type || (type !== 'internal' && type !== 'external')) { + return res.status(400).json({ success: false, - message: '유효하지 않은 ID입니다.', + message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)" }); - return; } - const job = await BatchService.getBatchJobById(id); - if (!job) { - res.status(404).json({ + const connectionId = type === 'external' ? Number(id) : undefined; + const result = await BatchService.getTablesFromConnection(type, connectionId); + + if (result.success) { + return res.json(result); + } else { + return res.status(500).json(result); + } + } catch (error) { + console.error("테이블 목록 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "테이블 목록 조회에 실패했습니다." + }); + } + } + + /** + * 테이블 컬럼 정보 조회 (내부/외부 DB) + * GET /api/batch-configs/connections/:type/tables/:tableName/columns + * GET /api/batch-configs/connections/:type/:id/tables/:tableName/columns + */ + static async getTableColumns(req: AuthenticatedRequest, res: Response) { + try { + const { type, id, tableName } = req.params; + + if (!type || !tableName) { + return res.status(400).json({ success: false, - message: '배치 작업을 찾을 수 없습니다.', + message: "연결 타입과 테이블명을 모두 지정해주세요." }); - return; } - res.status(200).json({ - success: true, - data: job, - message: '배치 작업을 조회했습니다.', - }); - } catch (error) { - console.error('배치 작업 조회 오류:', error); - res.status(500).json({ - success: false, - message: error instanceof Error ? error.message : '배치 작업 조회에 실패했습니다.', - }); - } - } - - /** - * 배치 작업 생성 - */ - static async createBatchJob(req: AuthenticatedRequest, res: Response): Promise { - try { - const data: BatchJob = { - ...req.body, - company_code: req.user?.companyCode || '*', - created_by: req.user?.userId, - }; - - // 필수 필드 검증 - if (!data.job_name || !data.job_type) { - res.status(400).json({ + if (type !== 'internal' && type !== 'external') { + return res.status(400).json({ success: false, - message: '필수 필드가 누락되었습니다.', + message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)" }); - return; } - const job = await BatchService.createBatchJob(data); - - res.status(201).json({ - success: true, - data: job, - message: '배치 작업을 생성했습니다.', - }); + const connectionId = type === 'external' ? Number(id) : undefined; + const result = await BatchService.getTableColumns(type, connectionId, tableName); + + if (result.success) { + return res.json(result); + } else { + return res.status(500).json(result); + } } catch (error) { - console.error('배치 작업 생성 오류:', error); - res.status(500).json({ + console.error("컬럼 정보 조회 오류:", error); + return res.status(500).json({ success: false, - message: error instanceof Error ? error.message : '배치 작업 생성에 실패했습니다.', + message: "컬럼 정보 조회에 실패했습니다." }); } } /** - * 배치 작업 수정 + * 특정 배치 설정 조회 + * GET /api/batch-configs/:id */ - static async updateBatchJob(req: AuthenticatedRequest, res: Response): Promise { + static async getBatchConfigById(req: AuthenticatedRequest, res: Response) { try { - const id = parseInt(req.params.id); - if (isNaN(id)) { - res.status(400).json({ + const { id } = req.params; + const batchConfig = await BatchService.getBatchConfigById(Number(id)); + + if (!batchConfig) { + return res.status(404).json({ success: false, - message: '유효하지 않은 ID입니다.', + message: "배치 설정을 찾을 수 없습니다." + }); + } + + return res.json({ + success: true, + data: batchConfig + }); + } catch (error) { + console.error("배치 설정 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "배치 설정 조회에 실패했습니다." + }); + } + } + + /** + * 배치 설정 생성 + * POST /api/batch-configs + */ + static async createBatchConfig(req: AuthenticatedRequest, res: Response) { + try { + const { batchName, description, cronSchedule, mappings } = req.body; + + if (!batchName || !cronSchedule || !mappings || !Array.isArray(mappings)) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (batchName, cronSchedule, mappings)" }); - return; } - const data: Partial = { - ...req.body, - updated_by: req.user?.userId, - }; - - const job = await BatchService.updateBatchJob(id, data); - - res.status(200).json({ + const batchConfig = await BatchService.createBatchConfig({ + batchName, + description, + cronSchedule, + mappings + } as CreateBatchConfigRequest); + + return res.status(201).json({ success: true, - data: job, - message: '배치 작업을 수정했습니다.', + data: batchConfig, + message: "배치 설정이 성공적으로 생성되었습니다." }); } catch (error) { - console.error('배치 작업 수정 오류:', error); - res.status(500).json({ + console.error("배치 설정 생성 오류:", error); + return res.status(500).json({ success: false, - message: error instanceof Error ? error.message : '배치 작업 수정에 실패했습니다.', + message: "배치 설정 생성에 실패했습니다." }); } } /** - * 배치 작업 삭제 + * 배치 설정 수정 + * PUT /api/batch-configs/:id */ - static async deleteBatchJob(req: AuthenticatedRequest, res: Response): Promise { + static async updateBatchConfig(req: AuthenticatedRequest, res: Response) { try { - const id = parseInt(req.params.id); - if (isNaN(id)) { - res.status(400).json({ + const { id } = req.params; + const { batchName, description, cronSchedule, mappings, isActive } = req.body; + + if (!batchName || !cronSchedule) { + return res.status(400).json({ success: false, - message: '유효하지 않은 ID입니다.', + message: "필수 필드가 누락되었습니다. (batchName, cronSchedule)" }); - return; } - await BatchService.deleteBatchJob(id); - - res.status(200).json({ - success: true, - message: '배치 작업을 삭제했습니다.', - }); - } catch (error) { - console.error('배치 작업 삭제 오류:', error); - res.status(500).json({ - success: false, - message: error instanceof Error ? error.message : '배치 작업 삭제에 실패했습니다.', - }); - } - } - - /** - * 배치 작업 수동 실행 - */ - static async executeBatchJob(req: AuthenticatedRequest, res: Response): Promise { - try { - const id = parseInt(req.params.id); - if (isNaN(id)) { - res.status(400).json({ + const batchConfig = await BatchService.updateBatchConfig(Number(id), { + batchName, + description, + cronSchedule, + mappings, + isActive + } as UpdateBatchConfigRequest); + + if (!batchConfig) { + return res.status(404).json({ success: false, - message: '유효하지 않은 ID입니다.', + message: "배치 설정을 찾을 수 없습니다." }); - return; } - - const execution = await BatchService.executeBatchJob(id); - - res.status(200).json({ + + return res.json({ success: true, - data: execution, - message: '배치 작업을 실행했습니다.', + data: batchConfig, + message: "배치 설정이 성공적으로 수정되었습니다." }); } catch (error) { - console.error('배치 작업 실행 오류:', error); - res.status(500).json({ + console.error("배치 설정 수정 오류:", error); + return res.status(500).json({ success: false, - message: error instanceof Error ? error.message : '배치 작업 실행에 실패했습니다.', + message: "배치 설정 수정에 실패했습니다." }); } } /** - * 배치 실행 목록 조회 + * 배치 설정 삭제 (논리 삭제) + * DELETE /api/batch-configs/:id */ - static async getBatchExecutions(req: AuthenticatedRequest, res: Response): Promise { + static async deleteBatchConfig(req: AuthenticatedRequest, res: Response) { try { - const jobId = req.query.job_id ? parseInt(req.query.job_id as string) : undefined; - const executions = await BatchService.getBatchExecutions(jobId); - - res.status(200).json({ + const { id } = req.params; + const result = await BatchService.deleteBatchConfig(Number(id)); + + if (!result) { + return res.status(404).json({ + success: false, + message: "배치 설정을 찾을 수 없습니다." + }); + } + + return res.json({ success: true, - data: executions, - message: '배치 실행 목록을 조회했습니다.', + message: "배치 설정이 성공적으로 삭제되었습니다." }); } catch (error) { - console.error('배치 실행 목록 조회 오류:', error); - res.status(500).json({ + console.error("배치 설정 삭제 오류:", error); + return res.status(500).json({ success: false, - message: error instanceof Error ? error.message : '배치 실행 목록 조회에 실패했습니다.', + message: "배치 설정 삭제에 실패했습니다." }); } } - - /** - * 배치 모니터링 정보 조회 - */ - static async getBatchMonitoring(req: AuthenticatedRequest, res: Response): Promise { - try { - const monitoring = await BatchService.getBatchMonitoring(); - - res.status(200).json({ - success: true, - data: monitoring, - message: '배치 모니터링 정보를 조회했습니다.', - }); - } catch (error) { - console.error('배치 모니터링 조회 오류:', error); - res.status(500).json({ - success: false, - message: error instanceof Error ? error.message : '배치 모니터링 조회에 실패했습니다.', - }); - } - } - - /** - * 지원되는 작업 타입 조회 - */ - static async getSupportedJobTypes(req: AuthenticatedRequest, res: Response): Promise { - try { - const { BATCH_JOB_TYPE_OPTIONS } = await import('../types/batchManagement'); - - res.status(200).json({ - success: true, - data: { - types: BATCH_JOB_TYPE_OPTIONS, - }, - message: '지원하는 작업 타입 목록을 조회했습니다.', - }); - } catch (error) { - console.error('작업 타입 조회 오류:', error); - res.status(500).json({ - success: false, - message: '작업 타입 조회에 실패했습니다.', - }); - } - } - - /** - * 스케줄 프리셋 조회 - */ - static async getSchedulePresets(req: AuthenticatedRequest, res: Response): Promise { - try { - const { SCHEDULE_PRESETS } = await import('../types/batchManagement'); - - res.status(200).json({ - success: true, - data: { - presets: SCHEDULE_PRESETS, - }, - message: '스케줄 프리셋 목록을 조회했습니다.', - }); - } catch (error) { - console.error('스케줄 프리셋 조회 오류:', error); - res.status(500).json({ - success: false, - message: '스케줄 프리셋 조회에 실패했습니다.', - }); - } - } -} +} \ No newline at end of file diff --git a/backend-node/src/controllers/batchExecutionLogController.ts b/backend-node/src/controllers/batchExecutionLogController.ts new file mode 100644 index 00000000..68d8d880 --- /dev/null +++ b/backend-node/src/controllers/batchExecutionLogController.ts @@ -0,0 +1,179 @@ +// 배치 실행 로그 컨트롤러 +// 작성일: 2024-12-24 + +import { Request, Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { BatchExecutionLogService } from "../services/batchExecutionLogService"; +import { BatchExecutionLogFilter, CreateBatchExecutionLogRequest, UpdateBatchExecutionLogRequest } from "../types/batchExecutionLogTypes"; + +export class BatchExecutionLogController { + /** + * 배치 실행 로그 목록 조회 + */ + static async getExecutionLogs(req: AuthenticatedRequest, res: Response) { + try { + const { + batch_config_id, + execution_status, + start_date, + end_date, + page, + limit + } = req.query; + + const filter: BatchExecutionLogFilter = { + batch_config_id: batch_config_id ? Number(batch_config_id) : undefined, + execution_status: execution_status as string, + start_date: start_date ? new Date(start_date as string) : undefined, + end_date: end_date ? new Date(end_date as string) : undefined, + page: page ? Number(page) : undefined, + limit: limit ? Number(limit) : undefined + }; + + const result = await BatchExecutionLogService.getExecutionLogs(filter); + + if (result.success) { + res.json(result); + } else { + res.status(500).json(result); + } + } catch (error) { + console.error("배치 실행 로그 조회 오류:", error); + res.status(500).json({ + success: false, + message: "배치 실행 로그 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } + + /** + * 배치 실행 로그 생성 + */ + static async createExecutionLog(req: AuthenticatedRequest, res: Response) { + try { + const data: CreateBatchExecutionLogRequest = req.body; + + const result = await BatchExecutionLogService.createExecutionLog(data); + + if (result.success) { + res.status(201).json(result); + } else { + res.status(500).json(result); + } + } catch (error) { + console.error("배치 실행 로그 생성 오류:", error); + res.status(500).json({ + success: false, + message: "배치 실행 로그 생성 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } + + /** + * 배치 실행 로그 업데이트 + */ + static async updateExecutionLog(req: AuthenticatedRequest, res: Response) { + try { + const { id } = req.params; + const data: UpdateBatchExecutionLogRequest = req.body; + + const result = await BatchExecutionLogService.updateExecutionLog(Number(id), data); + + if (result.success) { + res.json(result); + } else { + res.status(500).json(result); + } + } catch (error) { + console.error("배치 실행 로그 업데이트 오류:", error); + res.status(500).json({ + success: false, + message: "배치 실행 로그 업데이트 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } + + /** + * 배치 실행 로그 삭제 + */ + static async deleteExecutionLog(req: AuthenticatedRequest, res: Response) { + try { + const { id } = req.params; + + const result = await BatchExecutionLogService.deleteExecutionLog(Number(id)); + + if (result.success) { + res.json(result); + } else { + res.status(500).json(result); + } + } catch (error) { + console.error("배치 실행 로그 삭제 오류:", error); + res.status(500).json({ + success: false, + message: "배치 실행 로그 삭제 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } + + /** + * 특정 배치의 최신 실행 로그 조회 + */ + static async getLatestExecutionLog(req: AuthenticatedRequest, res: Response) { + try { + const { batchConfigId } = req.params; + + const result = await BatchExecutionLogService.getLatestExecutionLog(Number(batchConfigId)); + + if (result.success) { + res.json(result); + } else { + res.status(500).json(result); + } + } catch (error) { + console.error("최신 배치 실행 로그 조회 오류:", error); + res.status(500).json({ + success: false, + message: "최신 배치 실행 로그 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } + + /** + * 배치 실행 통계 조회 + */ + static async getExecutionStats(req: AuthenticatedRequest, res: Response) { + try { + const { + batch_config_id, + start_date, + end_date + } = req.query; + + const result = await BatchExecutionLogService.getExecutionStats( + batch_config_id ? Number(batch_config_id) : undefined, + start_date ? new Date(start_date as string) : undefined, + end_date ? new Date(end_date as string) : undefined + ); + + if (result.success) { + res.json(result); + } else { + res.status(500).json(result); + } + } catch (error) { + console.error("배치 실행 통계 조회 오류:", error); + res.status(500).json({ + success: false, + message: "배치 실행 통계 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } +} + diff --git a/backend-node/src/controllers/batchManagementController.ts b/backend-node/src/controllers/batchManagementController.ts new file mode 100644 index 00000000..4381a340 --- /dev/null +++ b/backend-node/src/controllers/batchManagementController.ts @@ -0,0 +1,619 @@ +// 배치관리 전용 컨트롤러 (기존 소스와 완전 분리) +// 작성일: 2024-12-24 + +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { BatchManagementService, BatchConnectionInfo, BatchTableInfo, BatchColumnInfo } from "../services/batchManagementService"; +import { BatchService } from "../services/batchService"; +import { BatchSchedulerService } from "../services/batchSchedulerService"; +import { BatchExternalDbService } from "../services/batchExternalDbService"; +import { CreateBatchConfigRequest, BatchConfig } from "../types/batchTypes"; + +export class BatchManagementController { + /** + * 사용 가능한 커넥션 목록 조회 + */ + static async getAvailableConnections(req: AuthenticatedRequest, res: Response) { + try { + const result = await BatchManagementService.getAvailableConnections(); + if (result.success) { + res.json(result); + } else { + res.status(500).json(result); + } + } catch (error) { + console.error("커넥션 목록 조회 오류:", error); + res.status(500).json({ + success: false, + message: "커넥션 목록 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } + + /** + * 특정 커넥션의 테이블 목록 조회 + */ + static async getTablesFromConnection(req: AuthenticatedRequest, res: Response) { + try { + const { type, id } = req.params; + + if (type !== 'internal' && type !== 'external') { + return res.status(400).json({ + success: false, + message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)" + }); + } + + const connectionId = type === 'external' ? Number(id) : undefined; + const result = await BatchManagementService.getTablesFromConnection(type, connectionId); + + if (result.success) { + return res.json(result); + } else { + return res.status(500).json(result); + } + } catch (error) { + console.error("테이블 목록 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "테이블 목록 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } + + /** + * 특정 테이블의 컬럼 정보 조회 + */ + static async getTableColumns(req: AuthenticatedRequest, res: Response) { + try { + const { type, id, tableName } = req.params; + + if (type !== 'internal' && type !== 'external') { + return res.status(400).json({ + success: false, + message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)" + }); + } + + const connectionId = type === 'external' ? Number(id) : undefined; + const result = await BatchManagementService.getTableColumns(type, connectionId, tableName); + + if (result.success) { + return res.json(result); + } else { + return res.status(500).json(result); + } + } catch (error) { + console.error("컬럼 정보 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "컬럼 정보 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } + + /** + * 배치 설정 생성 + * POST /api/batch-management/batch-configs + */ + static async createBatchConfig(req: AuthenticatedRequest, res: Response) { + try { + const { batchName, description, cronSchedule, mappings, isActive } = req.body; + + if (!batchName || !cronSchedule || !mappings || !Array.isArray(mappings)) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (batchName, cronSchedule, mappings)" + }); + } + + const batchConfig = await BatchService.createBatchConfig({ + batchName, + description, + cronSchedule, + mappings, + isActive: isActive !== undefined ? isActive : true + } as CreateBatchConfigRequest); + + return res.status(201).json({ + success: true, + data: batchConfig, + message: "배치 설정이 성공적으로 생성되었습니다." + }); + } catch (error) { + console.error("배치 설정 생성 오류:", error); + return res.status(500).json({ + success: false, + message: "배치 설정 생성에 실패했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } + + /** + * 특정 배치 설정 조회 + * GET /api/batch-management/batch-configs/:id + */ + static async getBatchConfigById(req: AuthenticatedRequest, res: Response) { + try { + const { id } = req.params; + console.log("🔍 배치 설정 조회 요청:", id); + + const result = await BatchService.getBatchConfigById(Number(id)); + + if (!result.success) { + return res.status(404).json({ + success: false, + message: result.message || "배치 설정을 찾을 수 없습니다." + }); + } + + console.log("📋 조회된 배치 설정:", result.data); + + return res.json({ + success: true, + data: result.data + }); + } catch (error) { + console.error("❌ 배치 설정 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "배치 설정 조회에 실패했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } + + /** + * 배치 설정 목록 조회 + * GET /api/batch-management/batch-configs + */ + static async getBatchConfigs(req: AuthenticatedRequest, res: Response) { + try { + const { page = 1, limit = 10, search, isActive } = req.query; + + const filter = { + page: Number(page), + limit: Number(limit), + search: search as string, + is_active: isActive as string + }; + + const result = await BatchService.getBatchConfigs(filter); + + res.json({ + success: true, + data: result.data, + pagination: result.pagination + }); + } catch (error) { + console.error("배치 설정 목록 조회 오류:", error); + res.status(500).json({ + success: false, + message: "배치 설정 목록 조회에 실패했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } + + /** + * 배치 수동 실행 + * POST /api/batch-management/batch-configs/:id/execute + */ + static async executeBatchConfig(req: AuthenticatedRequest, res: Response) { + try { + const { id } = req.params; + + if (!id || isNaN(Number(id))) { + return res.status(400).json({ + success: false, + message: "올바른 배치 설정 ID를 제공해주세요." + }); + } + + // 배치 설정 조회 + const batchConfigResult = await BatchService.getBatchConfigById(Number(id)); + if (!batchConfigResult.success || !batchConfigResult.data) { + return res.status(404).json({ + success: false, + message: "배치 설정을 찾을 수 없습니다." + }); + } + + const batchConfig = batchConfigResult.data as BatchConfig; + + // 배치 실행 로직 (간단한 버전) + const startTime = new Date(); + let totalRecords = 0; + let successRecords = 0; + let failedRecords = 0; + + try { + console.log(`배치 실행 시작: ${batchConfig.batch_name} (ID: ${id})`); + + // 실행 로그 생성 + const executionLog = await BatchService.createExecutionLog({ + batch_config_id: Number(id), + execution_status: 'RUNNING', + start_time: startTime, + total_records: 0, + success_records: 0, + failed_records: 0 + }); + + // 실제 배치 실행 (매핑이 있는 경우) + if (batchConfig.batch_mappings && batchConfig.batch_mappings.length > 0) { + // 테이블별로 매핑을 그룹화 + const tableGroups = new Map(); + + for (const mapping of batchConfig.batch_mappings) { + const key = `${mapping.from_connection_type}:${mapping.from_connection_id || 'internal'}:${mapping.from_table_name}`; + if (!tableGroups.has(key)) { + tableGroups.set(key, []); + } + tableGroups.get(key)!.push(mapping); + } + + // 각 테이블 그룹별로 처리 + for (const [tableKey, mappings] of tableGroups) { + try { + const firstMapping = mappings[0]; + console.log(`테이블 처리 시작: ${tableKey} -> ${mappings.length}개 컬럼 매핑`); + + let fromData: any[] = []; + + // FROM 데이터 조회 (DB 또는 REST API) + if (firstMapping.from_connection_type === 'restapi') { + // REST API에서 데이터 조회 + console.log(`REST API에서 데이터 조회: ${firstMapping.from_api_url}${firstMapping.from_table_name}`); + console.log(`API 설정:`, { + url: firstMapping.from_api_url, + key: firstMapping.from_api_key ? '***' : 'null', + method: firstMapping.from_api_method, + endpoint: firstMapping.from_table_name + }); + + try { + const apiResult = await BatchExternalDbService.getDataFromRestApi( + firstMapping.from_api_url!, + firstMapping.from_api_key!, + firstMapping.from_table_name, + firstMapping.from_api_method as 'GET' | 'POST' | 'PUT' | 'DELETE' || 'GET', + mappings.map(m => m.from_column_name) + ); + + console.log(`API 조회 결과:`, { + success: apiResult.success, + dataCount: apiResult.data ? apiResult.data.length : 0, + message: apiResult.message + }); + + if (apiResult.success && apiResult.data) { + fromData = apiResult.data; + } else { + throw new Error(`REST API 데이터 조회 실패: ${apiResult.message}`); + } + } catch (error) { + console.error(`REST API 조회 오류:`, error); + throw error; + } + } else { + // DB에서 데이터 조회 + const fromColumns = mappings.map(m => m.from_column_name); + fromData = await BatchService.getDataFromTableWithColumns( + firstMapping.from_table_name, + fromColumns, + firstMapping.from_connection_type as 'internal' | 'external', + firstMapping.from_connection_id || undefined + ); + } + + totalRecords += fromData.length; + + // 컬럼 매핑 적용하여 TO 테이블 형식으로 변환 + const mappedData = fromData.map(row => { + const mappedRow: any = {}; + for (const mapping of mappings) { + // DB → REST API 배치인지 확인 + if (firstMapping.to_connection_type === 'restapi' && mapping.to_api_body) { + // DB → REST API: 원본 컬럼명을 키로 사용 (템플릿 처리용) + mappedRow[mapping.from_column_name] = row[mapping.from_column_name]; + } else { + // 기존 로직: to_column_name을 키로 사용 + mappedRow[mapping.to_column_name] = row[mapping.from_column_name]; + } + } + return mappedRow; + }); + + // TO 테이블에 데이터 삽입 (DB 또는 REST API) + let insertResult: { successCount: number; failedCount: number }; + + if (firstMapping.to_connection_type === 'restapi') { + // REST API로 데이터 전송 + console.log(`REST API로 데이터 전송: ${firstMapping.to_api_url}${firstMapping.to_table_name}`); + + // DB → REST API 배치인지 확인 (to_api_body가 있으면 템플릿 기반) + const hasTemplate = mappings.some(m => m.to_api_body); + + if (hasTemplate) { + // 템플릿 기반 REST API 전송 (DB → REST API 배치) + const templateBody = firstMapping.to_api_body || '{}'; + console.log(`템플릿 기반 REST API 전송, Request Body 템플릿: ${templateBody}`); + + // URL 경로 컬럼 찾기 (PUT/DELETE용) + const urlPathColumn = mappings.find(m => m.to_column_name === 'URL_PATH_PARAM')?.from_column_name; + + const apiResult = await BatchExternalDbService.sendDataToRestApiWithTemplate( + firstMapping.to_api_url!, + firstMapping.to_api_key!, + firstMapping.to_table_name, + firstMapping.to_api_method as 'POST' | 'PUT' | 'DELETE' || 'POST', + templateBody, + mappedData, + urlPathColumn + ); + + if (apiResult.success && apiResult.data) { + insertResult = apiResult.data; + } else { + throw new Error(`템플릿 기반 REST API 데이터 전송 실패: ${apiResult.message}`); + } + } else { + // 기존 REST API 전송 (REST API → DB 배치) + const apiResult = await BatchExternalDbService.sendDataToRestApi( + firstMapping.to_api_url!, + firstMapping.to_api_key!, + firstMapping.to_table_name, + firstMapping.to_api_method as 'POST' | 'PUT' || 'POST', + mappedData + ); + + if (apiResult.success && apiResult.data) { + insertResult = apiResult.data; + } else { + throw new Error(`REST API 데이터 전송 실패: ${apiResult.message}`); + } + } + } else { + // DB에 데이터 삽입 + insertResult = await BatchService.insertDataToTable( + firstMapping.to_table_name, + mappedData, + firstMapping.to_connection_type as 'internal' | 'external', + firstMapping.to_connection_id || undefined + ); + } + + successRecords += insertResult.successCount; + failedRecords += insertResult.failedCount; + + console.log(`테이블 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`); + } catch (error) { + console.error(`테이블 처리 실패: ${tableKey}`, error); + failedRecords += 1; + } + } + } else { + console.log("매핑이 없어서 데이터 처리를 건너뜁니다."); + } + + // 실행 로그 업데이트 (성공) + await BatchService.updateExecutionLog(executionLog.id, { + execution_status: 'SUCCESS', + end_time: new Date(), + duration_ms: Date.now() - startTime.getTime(), + total_records: totalRecords, + success_records: successRecords, + failed_records: failedRecords + }); + + return res.json({ + success: true, + message: "배치가 성공적으로 실행되었습니다.", + data: { + batchId: id, + totalRecords, + successRecords, + failedRecords, + duration: Date.now() - startTime.getTime() + } + }); + } catch (error) { + console.error(`배치 실행 실패: ${batchConfig.batch_name}`, error); + + return res.status(500).json({ + success: false, + message: "배치 실행에 실패했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } catch (error) { + console.error("배치 실행 오류:", error); + return res.status(500).json({ + success: false, + message: "배치 실행 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } + + /** + * 배치 설정 업데이트 + * PUT /api/batch-management/batch-configs/:id + */ + static async updateBatchConfig(req: AuthenticatedRequest, res: Response) { + try { + const { id } = req.params; + const updateData = req.body; + + if (!id || isNaN(Number(id))) { + return res.status(400).json({ + success: false, + message: "올바른 배치 설정 ID를 제공해주세요." + }); + } + + const batchConfig = await BatchService.updateBatchConfig(Number(id), updateData); + + // 스케줄러에서 배치 스케줄 업데이트 + await BatchSchedulerService.updateBatchSchedule(Number(id)); + + return res.json({ + success: true, + data: batchConfig, + message: "배치 설정이 성공적으로 업데이트되었습니다." + }); + } catch (error) { + console.error("배치 설정 업데이트 오류:", error); + return res.status(500).json({ + success: false, + message: "배치 설정 업데이트에 실패했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } + + /** + * REST API 데이터 미리보기 + */ + static async previewRestApiData(req: AuthenticatedRequest, res: Response) { + try { + const { apiUrl, apiKey, endpoint, method = 'GET' } = req.body; + + if (!apiUrl || !apiKey || !endpoint) { + return res.status(400).json({ + success: false, + message: "API URL, API Key, 엔드포인트는 필수입니다." + }); + } + + // RestApiConnector 사용하여 데이터 조회 + const { RestApiConnector } = await import('../database/RestApiConnector'); + + const connector = new RestApiConnector({ + baseUrl: apiUrl, + apiKey: apiKey, + timeout: 30000 + }); + + // 연결 테스트 + await connector.connect(); + + // 데이터 조회 (최대 5개만) - GET 메서드만 지원 + const result = await connector.executeQuery(endpoint, method); + console.log(`[previewRestApiData] executeQuery 결과:`, { + rowCount: result.rowCount, + rowsLength: result.rows ? result.rows.length : 'undefined', + firstRow: result.rows && result.rows.length > 0 ? result.rows[0] : 'no data' + }); + + const data = result.rows.slice(0, 5); // 최대 5개 샘플만 + console.log(`[previewRestApiData] 슬라이스된 데이터:`, data); + + if (data.length > 0) { + // 첫 번째 객체에서 필드명 추출 + const fields = Object.keys(data[0]); + console.log(`[previewRestApiData] 추출된 필드:`, fields); + + return res.json({ + success: true, + data: { + fields: fields, + samples: data, + totalCount: result.rowCount || data.length + }, + message: `${fields.length}개 필드, ${result.rowCount || data.length}개 레코드를 조회했습니다.` + }); + } else { + return res.json({ + success: true, + data: { + fields: [], + samples: [], + totalCount: 0 + }, + message: "API에서 데이터를 가져올 수 없습니다." + }); + } + } catch (error) { + console.error("REST API 미리보기 오류:", error); + return res.status(500).json({ + success: false, + message: "REST API 데이터 미리보기 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } + + /** + * REST API 배치 설정 저장 + */ + static async saveRestApiBatch(req: AuthenticatedRequest, res: Response) { + try { + const { + batchName, + batchType, + cronSchedule, + description, + apiMappings + } = req.body; + + if (!batchName || !batchType || !cronSchedule || !apiMappings || apiMappings.length === 0) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다." + }); + } + + console.log("REST API 배치 저장 요청:", { + batchName, + batchType, + cronSchedule, + description, + apiMappings + }); + + // BatchService를 사용하여 배치 설정 저장 + const batchConfig: CreateBatchConfigRequest = { + batchName: batchName, + description: description || '', + cronSchedule: cronSchedule, + mappings: apiMappings + }; + + const result = await BatchService.createBatchConfig(batchConfig); + + if (result.success && result.data) { + // 스케줄러에 자동 등록 ✅ + try { + await BatchSchedulerService.scheduleBatchConfig(result.data); + console.log(`✅ 새로운 배치가 스케줄러에 등록되었습니다: ${batchName} (ID: ${result.data.id})`); + } catch (schedulerError) { + console.error(`❌ 스케줄러 등록 실패: ${batchName}`, schedulerError); + // 스케줄러 등록 실패해도 배치 저장은 성공으로 처리 + } + + return res.json({ + success: true, + message: "REST API 배치가 성공적으로 저장되었습니다.", + data: result.data + }); + } else { + return res.status(500).json({ + success: false, + message: result.message || "배치 저장에 실패했습니다." + }); + } + } catch (error) { + console.error("REST API 배치 저장 오류:", error); + return res.status(500).json({ + success: false, + message: "배치 저장 중 오류가 발생했습니다." + }); + } + } +} diff --git a/backend-node/src/controllers/commonCodeController.ts b/backend-node/src/controllers/commonCodeController.ts index cf9637d3..482ac6d1 100644 --- a/backend-node/src/controllers/commonCodeController.ts +++ b/backend-node/src/controllers/commonCodeController.ts @@ -63,9 +63,19 @@ export class CommonCodeController { size: size ? parseInt(size as string) : undefined, }); + // 프론트엔드가 기대하는 형식으로 데이터 변환 + const transformedData = result.data.map((code: any) => ({ + codeValue: code.code_value, + codeName: code.code_name, + description: code.description, + sortOrder: code.sort_order, + isActive: code.is_active === "Y", + useYn: code.is_active, + })); + return res.json({ success: true, - data: result.data, + data: transformedData, total: result.total, message: `코드 목록 조회 성공 (${categoryCode})`, }); diff --git a/backend-node/src/controllers/fileController.ts b/backend-node/src/controllers/fileController.ts index 60251f58..2528d3f1 100644 --- a/backend-node/src/controllers/fileController.ts +++ b/backend-node/src/controllers/fileController.ts @@ -8,6 +8,9 @@ import { generateUUID } from "../utils/generateId"; const prisma = new PrismaClient(); +// 임시 토큰 저장소 (메모리 기반, 실제 운영에서는 Redis 사용 권장) +const tempTokens = new Map(); + // 업로드 디렉토리 설정 (회사별로 분리) const baseUploadDir = path.join(process.cwd(), "uploads"); @@ -266,9 +269,7 @@ export const uploadFiles = async ( // 회사코드가 *인 경우 company_*로 변환 const actualCompanyCode = companyCode === "*" ? "company_*" : companyCode; - const relativePath = `/${actualCompanyCode}/${dateFolder}/${file.filename}`; - const fullFilePath = `/uploads${relativePath}`; - + // 임시 파일을 최종 위치로 이동 const tempFilePath = file.path; // Multer가 저장한 임시 파일 경로 const finalUploadDir = getCompanyUploadDir(companyCode, dateFolder); @@ -277,6 +278,10 @@ export const uploadFiles = async ( // 파일 이동 fs.renameSync(tempFilePath, finalFilePath); + // DB에 저장할 경로 (실제 파일 위치와 일치) + const relativePath = `/${actualCompanyCode}/${dateFolder}/${file.filename}`; + const fullFilePath = `/uploads${relativePath}`; + // attach_file_info 테이블에 저장 const fileRecord = await prisma.attach_file_info.create({ data: { @@ -485,6 +490,133 @@ export const getFileList = async ( } }; +/** + * 컴포넌트의 템플릿 파일과 데이터 파일을 모두 조회 + */ +export const getComponentFiles = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { screenId, componentId, tableName, recordId, columnName } = req.query; + + console.log("📂 [getComponentFiles] API 호출:", { + screenId, + componentId, + tableName, + recordId, + columnName, + user: req.user?.userId + }); + + if (!screenId || !componentId) { + console.log("❌ [getComponentFiles] 필수 파라미터 누락"); + res.status(400).json({ + success: false, + message: "screenId와 componentId가 필요합니다.", + }); + return; + } + + // 1. 템플릿 파일 조회 (화면 설계 시 업로드한 파일들) + const templateTargetObjid = `screen_files:${screenId}:${componentId}:${columnName || 'field_1'}`; + console.log("🔍 [getComponentFiles] 템플릿 파일 조회:", { templateTargetObjid }); + + // 모든 파일 조회해서 실제 저장된 target_objid 패턴 확인 + const allFiles = await prisma.attach_file_info.findMany({ + where: { + status: "ACTIVE", + }, + select: { + target_objid: true, + real_file_name: true, + regdate: true, + }, + orderBy: { + regdate: "desc", + }, + take: 10, + }); + console.log("🗂️ [getComponentFiles] 최근 저장된 파일들의 target_objid:", allFiles.map(f => ({ target_objid: f.target_objid, name: f.real_file_name }))); + + const templateFiles = await prisma.attach_file_info.findMany({ + where: { + target_objid: templateTargetObjid, + status: "ACTIVE", + }, + orderBy: { + regdate: "desc", + }, + }); + + console.log("📁 [getComponentFiles] 템플릿 파일 결과:", templateFiles.length); + + // 2. 데이터 파일 조회 (실제 레코드와 연결된 파일들) + let dataFiles: any[] = []; + if (tableName && recordId && columnName) { + const dataTargetObjid = `${tableName}:${recordId}:${columnName}`; + dataFiles = await prisma.attach_file_info.findMany({ + where: { + target_objid: dataTargetObjid, + status: "ACTIVE", + }, + orderBy: { + regdate: "desc", + }, + }); + } + + // 파일 정보 포맷팅 함수 + const formatFileInfo = (file: any, isTemplate: boolean = false) => ({ + objid: file.objid.toString(), + savedFileName: file.saved_file_name, + realFileName: file.real_file_name, + fileSize: Number(file.file_size), + fileExt: file.file_ext, + filePath: file.file_path, + docType: file.doc_type, + docTypeName: file.doc_type_name, + targetObjid: file.target_objid, + parentTargetObjid: file.parent_target_objid, + writer: file.writer, + regdate: file.regdate?.toISOString(), + status: file.status, + isTemplate, // 템플릿 파일 여부 표시 + }); + + const formattedTemplateFiles = templateFiles.map(file => formatFileInfo(file, true)); + const formattedDataFiles = dataFiles.map(file => formatFileInfo(file, false)); + + // 3. 전체 파일 목록 (데이터 파일 우선, 없으면 템플릿 파일 표시) + const totalFiles = formattedDataFiles.length > 0 + ? formattedDataFiles + : formattedTemplateFiles; + + res.json({ + success: true, + templateFiles: formattedTemplateFiles, + dataFiles: formattedDataFiles, + totalFiles, + summary: { + templateCount: formattedTemplateFiles.length, + dataCount: formattedDataFiles.length, + totalCount: totalFiles.length, + templateTargetObjid, + dataTargetObjid: tableName && recordId && columnName + ? `${tableName}:${recordId}:${columnName}` + : null, + }, + }); + } catch (error) { + console.error("컴포넌트 파일 조회 오류:", error); + res.status(500).json({ + success: false, + message: "컴포넌트 파일 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } +}; + /** * 파일 미리보기 (이미지 등) */ @@ -512,7 +644,13 @@ export const previewFile = async ( // 파일 경로에서 회사코드와 날짜 폴더 추출 const filePathParts = fileRecord.file_path!.split("/"); - const companyCode = filePathParts[2] || "DEFAULT"; + let companyCode = filePathParts[2] || "DEFAULT"; + + // company_* 처리 (실제 회사 코드로 변환) + if (companyCode === "company_*") { + companyCode = "company_*"; // 실제 디렉토리명 유지 + } + const fileName = fileRecord.saved_file_name!; // 파일 경로에 날짜 구조가 있는지 확인 (YYYY/MM/DD) @@ -527,6 +665,17 @@ export const previewFile = async ( ); const filePath = path.join(companyUploadDir, fileName); + console.log("🔍 파일 미리보기 경로 확인:", { + objid: objid, + filePathFromDB: fileRecord.file_path, + companyCode: companyCode, + dateFolder: dateFolder, + fileName: fileName, + companyUploadDir: companyUploadDir, + finalFilePath: filePath, + fileExists: fs.existsSync(filePath) + }); + if (!fs.existsSync(filePath)) { console.error("❌ 파일 없음:", filePath); res.status(404).json({ @@ -615,7 +764,13 @@ export const downloadFile = async ( // 파일 경로에서 회사코드와 날짜 폴더 추출 (예: /uploads/company_*/2025/09/05/timestamp_filename.ext) const filePathParts = fileRecord.file_path!.split("/"); - const companyCode = filePathParts[2] || "DEFAULT"; // /uploads/company_*/2025/09/05/filename.ext에서 company_* 추출 + let companyCode = filePathParts[2] || "DEFAULT"; // /uploads/company_*/2025/09/05/filename.ext에서 company_* 추출 + + // company_* 처리 (실제 회사 코드로 변환) + if (companyCode === "company_*") { + companyCode = "company_*"; // 실제 디렉토리명 유지 + } + const fileName = fileRecord.saved_file_name!; // 파일 경로에 날짜 구조가 있는지 확인 (YYYY/MM/DD) @@ -631,6 +786,17 @@ export const downloadFile = async ( ); const filePath = path.join(companyUploadDir, fileName); + console.log("🔍 파일 다운로드 경로 확인:", { + objid: objid, + filePathFromDB: fileRecord.file_path, + companyCode: companyCode, + dateFolder: dateFolder, + fileName: fileName, + companyUploadDir: companyUploadDir, + finalFilePath: filePath, + fileExists: fs.existsSync(filePath) + }); + if (!fs.existsSync(filePath)) { console.error("❌ 파일 없음:", filePath); res.status(404).json({ @@ -660,5 +826,178 @@ export const downloadFile = async ( } }; +/** + * Google Docs Viewer용 임시 공개 토큰 생성 + */ +export const generateTempToken = async (req: AuthenticatedRequest, res: Response) => { + try { + const { objid } = req.params; + + if (!objid) { + res.status(400).json({ + success: false, + message: "파일 ID가 필요합니다.", + }); + return; + } + + // 파일 존재 확인 + const fileRecord = await prisma.attach_file_info.findUnique({ + where: { objid: objid }, + }); + + if (!fileRecord) { + res.status(404).json({ + success: false, + message: "파일을 찾을 수 없습니다.", + }); + return; + } + + // 임시 토큰 생성 (30분 유효) + const token = generateUUID(); + const expires = Date.now() + 30 * 60 * 1000; // 30분 + + tempTokens.set(token, { + objid: objid, + expires: expires, + }); + + // 만료된 토큰 정리 (메모리 누수 방지) + const now = Date.now(); + for (const [key, value] of tempTokens.entries()) { + if (value.expires < now) { + tempTokens.delete(key); + } + } + + res.json({ + success: true, + data: { + token: token, + publicUrl: `${req.protocol}://${req.get("host")}/api/files/public/${token}`, + expires: new Date(expires).toISOString(), + }, + }); + } catch (error) { + console.error("❌ 임시 토큰 생성 오류:", error); + res.status(500).json({ + success: false, + message: "임시 토큰 생성에 실패했습니다.", + }); + } +}; + +/** + * 임시 토큰으로 파일 접근 (인증 불필요) + */ +export const getFileByToken = async (req: Request, res: Response) => { + try { + const { token } = req.params; + + if (!token) { + res.status(400).json({ + success: false, + message: "토큰이 필요합니다.", + }); + return; + } + + // 토큰 확인 + const tokenData = tempTokens.get(token); + if (!tokenData) { + res.status(404).json({ + success: false, + message: "유효하지 않은 토큰입니다.", + }); + return; + } + + // 토큰 만료 확인 + if (tokenData.expires < Date.now()) { + tempTokens.delete(token); + res.status(410).json({ + success: false, + message: "토큰이 만료되었습니다.", + }); + return; + } + + // 파일 정보 조회 + const fileRecord = await prisma.attach_file_info.findUnique({ + where: { objid: tokenData.objid }, + }); + + if (!fileRecord) { + res.status(404).json({ + success: false, + message: "파일을 찾을 수 없습니다.", + }); + return; + } + + // 파일 경로 구성 + const filePathParts = fileRecord.file_path!.split("/"); + let companyCode = filePathParts[2] || "DEFAULT"; + if (companyCode === "company_*") { + companyCode = "company_*"; // 실제 디렉토리명 유지 + } + const fileName = fileRecord.saved_file_name!; + let dateFolder = ""; + if (filePathParts.length >= 6) { + dateFolder = `${filePathParts[3]}/${filePathParts[4]}/${filePathParts[5]}`; + } + const companyUploadDir = getCompanyUploadDir(companyCode, dateFolder || undefined); + const filePath = path.join(companyUploadDir, fileName); + + // 파일 존재 확인 + if (!fs.existsSync(filePath)) { + res.status(404).json({ + success: false, + message: "실제 파일을 찾을 수 없습니다.", + }); + return; + } + + // MIME 타입 설정 + const ext = path.extname(fileName).toLowerCase(); + let contentType = "application/octet-stream"; + + const mimeTypes: { [key: string]: string } = { + ".pdf": "application/pdf", + ".doc": "application/msword", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".xls": "application/vnd.ms-excel", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".ppt": "application/vnd.ms-powerpoint", + ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".txt": "text/plain", + }; + + if (mimeTypes[ext]) { + contentType = mimeTypes[ext]; + } + + // 파일 헤더 설정 + res.setHeader("Content-Type", contentType); + res.setHeader("Content-Disposition", `inline; filename="${encodeURIComponent(fileRecord.real_file_name!)}"`); + res.setHeader("Cache-Control", "public, max-age=300"); // 5분 캐시 + + // 파일 스트림 전송 + const fileStream = fs.createReadStream(filePath); + fileStream.pipe(res); + } catch (error) { + console.error("❌ 토큰 파일 접근 오류:", error); + res.status(500).json({ + success: false, + message: "파일 접근에 실패했습니다.", + }); + } +}; + // Multer 미들웨어 export export const uploadMiddleware = upload.array("files", 10); // 최대 10개 파일 diff --git a/backend-node/src/database/DatabaseConnectorFactory.ts b/backend-node/src/database/DatabaseConnectorFactory.ts index 8ece7bba..f8d277bf 100644 --- a/backend-node/src/database/DatabaseConnectorFactory.ts +++ b/backend-node/src/database/DatabaseConnectorFactory.ts @@ -3,6 +3,7 @@ import { PostgreSQLConnector } from './PostgreSQLConnector'; import { MariaDBConnector } from './MariaDBConnector'; import { MSSQLConnector } from './MSSQLConnector'; import { OracleConnector } from './OracleConnector'; +import { RestApiConnector, RestApiConfig } from './RestApiConnector'; export class DatabaseConnectorFactory { private static connectors = new Map(); @@ -33,6 +34,9 @@ export class DatabaseConnectorFactory { case 'oracle': connector = new OracleConnector(config); break; + case 'restapi': + connector = new RestApiConnector(config as RestApiConfig); + break; // Add other database types here default: throw new Error(`지원하지 않는 데이터베이스 타입: ${type}`); diff --git a/backend-node/src/database/MSSQLConnector.ts b/backend-node/src/database/MSSQLConnector.ts index b4555a7e..fc1c195c 100644 --- a/backend-node/src/database/MSSQLConnector.ts +++ b/backend-node/src/database/MSSQLConnector.ts @@ -1,5 +1,6 @@ import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector'; import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes'; +// @ts-ignore import * as mssql from 'mssql'; export class MSSQLConnector implements DatabaseConnector { diff --git a/backend-node/src/database/MariaDBConnector.ts b/backend-node/src/database/MariaDBConnector.ts index 1926f183..f023bfc7 100644 --- a/backend-node/src/database/MariaDBConnector.ts +++ b/backend-node/src/database/MariaDBConnector.ts @@ -1,5 +1,6 @@ import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector'; import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes'; +// @ts-ignore import * as mysql from 'mysql2/promise'; export class MariaDBConnector implements DatabaseConnector { @@ -106,7 +107,10 @@ export class MariaDBConnector implements DatabaseConnector { async getColumns(tableName: string): Promise { try { + console.log(`[MariaDBConnector] getColumns 호출: tableName=${tableName}`); await this.connect(); + console.log(`[MariaDBConnector] 연결 완료, 쿼리 실행 시작`); + const [rows] = await this.connection!.query(` SELECT COLUMN_NAME as column_name, @@ -117,11 +121,16 @@ export class MariaDBConnector implements DatabaseConnector { WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION; `, [tableName]); + + console.log(`[MariaDBConnector] 쿼리 결과:`, rows); + console.log(`[MariaDBConnector] 결과 개수:`, Array.isArray(rows) ? rows.length : 'not array'); + await this.disconnect(); return rows as any[]; } catch (error: any) { + console.error(`[MariaDBConnector] getColumns 오류:`, error); await this.disconnect(); throw new Error(`컬럼 정보 조회 실패: ${error.message}`); } } -} \ No newline at end of file +} diff --git a/backend-node/src/database/OracleConnector.ts b/backend-node/src/database/OracleConnector.ts index a9fea5f6..b9360570 100644 --- a/backend-node/src/database/OracleConnector.ts +++ b/backend-node/src/database/OracleConnector.ts @@ -1,3 +1,4 @@ +// @ts-ignore import * as oracledb from 'oracledb'; import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector'; import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes'; @@ -100,7 +101,7 @@ export class OracleConnector implements DatabaseConnector { // Oracle XE 21c 쿼리 실행 옵션 const options: any = { - outFormat: oracledb.OUT_FORMAT_OBJECT, // OBJECT format + outFormat: (oracledb as any).OUT_FORMAT_OBJECT, // OBJECT format maxRows: 10000, // XE 제한 고려 fetchArraySize: 100 }; @@ -176,6 +177,8 @@ export class OracleConnector implements DatabaseConnector { async getColumns(tableName: string): Promise { try { + console.log(`[OracleConnector] getColumns 호출: tableName=${tableName}`); + const query = ` SELECT column_name, @@ -190,16 +193,23 @@ export class OracleConnector implements DatabaseConnector { ORDER BY column_id `; + console.log(`[OracleConnector] 쿼리 실행 시작: ${query}`); const result = await this.executeQuery(query, [tableName]); - return result.rows.map((row: any) => ({ + console.log(`[OracleConnector] 쿼리 결과:`, result.rows); + console.log(`[OracleConnector] 결과 개수:`, result.rows ? result.rows.length : 'null/undefined'); + + const mappedResult = result.rows.map((row: any) => ({ column_name: row.COLUMN_NAME, data_type: this.formatOracleDataType(row), is_nullable: row.NULLABLE === 'Y' ? 'YES' : 'NO', column_default: row.DATA_DEFAULT })); + + console.log(`[OracleConnector] 매핑된 결과:`, mappedResult); + return mappedResult; } catch (error: any) { - console.error('Oracle 테이블 컬럼 조회 실패:', error); + console.error('[OracleConnector] getColumns 오류:', error); throw new Error(`테이블 컬럼 조회 실패: ${error.message}`); } } diff --git a/backend-node/src/database/RestApiConnector.ts b/backend-node/src/database/RestApiConnector.ts new file mode 100644 index 00000000..98da0eb3 --- /dev/null +++ b/backend-node/src/database/RestApiConnector.ts @@ -0,0 +1,261 @@ +import axios, { AxiosInstance, AxiosResponse } from 'axios'; +import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector'; +import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes'; + +export interface RestApiConfig { + baseUrl: string; + apiKey: string; + timeout?: number; + // ConnectionConfig 호환성을 위한 더미 필드들 (사용하지 않음) + host?: string; + port?: number; + database?: string; + user?: string; + password?: string; +} + +export class RestApiConnector implements DatabaseConnector { + private httpClient: AxiosInstance; + private config: RestApiConfig; + + constructor(config: RestApiConfig) { + this.config = config; + + // Axios 인스턴스 생성 + this.httpClient = axios.create({ + baseURL: config.baseUrl, + timeout: config.timeout || 30000, + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': config.apiKey, + 'Accept': 'application/json' + } + }); + + // 요청/응답 인터셉터 설정 + this.setupInterceptors(); + } + + private setupInterceptors() { + // 요청 인터셉터 + this.httpClient.interceptors.request.use( + (config) => { + console.log(`[RestApiConnector] 요청: ${config.method?.toUpperCase()} ${config.url}`); + return config; + }, + (error) => { + console.error('[RestApiConnector] 요청 오류:', error); + return Promise.reject(error); + } + ); + + // 응답 인터셉터 + this.httpClient.interceptors.response.use( + (response) => { + console.log(`[RestApiConnector] 응답: ${response.status} ${response.statusText}`); + return response; + }, + (error) => { + console.error('[RestApiConnector] 응답 오류:', error.response?.status, error.response?.statusText); + return Promise.reject(error); + } + ); + } + + async connect(): Promise { + try { + // 연결 테스트 - 기본 엔드포인트 호출 + await this.httpClient.get('/health', { timeout: 5000 }); + console.log(`[RestApiConnector] 연결 성공: ${this.config.baseUrl}`); + } catch (error) { + // health 엔드포인트가 없을 수 있으므로 404는 정상으로 처리 + if (axios.isAxiosError(error) && error.response?.status === 404) { + console.log(`[RestApiConnector] 연결 성공 (health 엔드포인트 없음): ${this.config.baseUrl}`); + return; + } + console.error(`[RestApiConnector] 연결 실패: ${this.config.baseUrl}`, error); + throw new Error(`REST API 연결 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`); + } + } + + async disconnect(): Promise { + // REST API는 연결 해제가 필요 없음 + console.log(`[RestApiConnector] 연결 해제: ${this.config.baseUrl}`); + } + + async testConnection(): Promise { + try { + await this.connect(); + return { + success: true, + message: 'REST API 연결이 성공했습니다.', + details: { + response_time: Date.now() + } + }; + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : 'REST API 연결에 실패했습니다.', + details: { + response_time: Date.now() + } + }; + } + } + + async executeQuery(endpoint: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET', data?: any): Promise { + try { + const startTime = Date.now(); + let response: AxiosResponse; + + // HTTP 메서드에 따른 요청 실행 + switch (method.toUpperCase()) { + case 'GET': + response = await this.httpClient.get(endpoint); + break; + case 'POST': + response = await this.httpClient.post(endpoint, data); + break; + case 'PUT': + response = await this.httpClient.put(endpoint, data); + break; + case 'DELETE': + response = await this.httpClient.delete(endpoint); + break; + default: + throw new Error(`지원하지 않는 HTTP 메서드: ${method}`); + } + + const executionTime = Date.now() - startTime; + const responseData = response.data; + + console.log(`[RestApiConnector] 원본 응답 데이터:`, { + type: typeof responseData, + isArray: Array.isArray(responseData), + keys: typeof responseData === 'object' ? Object.keys(responseData) : 'not object', + responseData: responseData + }); + + // 응답 데이터 처리 + let rows: any[]; + if (Array.isArray(responseData)) { + rows = responseData; + } else if (responseData && responseData.data && Array.isArray(responseData.data)) { + // API 응답이 {success: true, data: [...]} 형태인 경우 + rows = responseData.data; + } else if (responseData && responseData.data && typeof responseData.data === 'object') { + // API 응답이 {success: true, data: {...}} 형태인 경우 (단일 객체) + rows = [responseData.data]; + } else if (responseData && typeof responseData === 'object' && !Array.isArray(responseData)) { + // 단일 객체 응답인 경우 + rows = [responseData]; + } else { + rows = []; + } + + console.log(`[RestApiConnector] 처리된 rows:`, { + rowsLength: rows.length, + firstRow: rows.length > 0 ? rows[0] : 'no data', + allRows: rows + }); + + console.log(`[RestApiConnector] API 호출 결과:`, { + endpoint, + method, + status: response.status, + rowCount: rows.length, + executionTime: `${executionTime}ms` + }); + + return { + rows: rows, + rowCount: rows.length, + fields: rows.length > 0 ? Object.keys(rows[0]).map(key => ({ name: key, type: 'string' })) : [] + }; + } catch (error) { + console.error(`[RestApiConnector] API 호출 오류 (${method} ${endpoint}):`, error); + + if (axios.isAxiosError(error)) { + throw new Error(`REST API 호출 실패: ${error.response?.status} ${error.response?.statusText}`); + } + + throw new Error(`REST API 호출 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`); + } + } + + async getTables(): Promise { + // REST API의 경우 "테이블"은 사용 가능한 엔드포인트를 의미 + // 일반적인 REST API 엔드포인트들을 반환 + return [ + { + table_name: '/api/users', + columns: [], + description: '사용자 정보 API' + }, + { + table_name: '/api/data', + columns: [], + description: '기본 데이터 API' + }, + { + table_name: '/api/custom', + columns: [], + description: '사용자 정의 엔드포인트' + } + ]; + } + + async getTableList(): Promise { + return this.getTables(); + } + + async getColumns(endpoint: string): Promise { + try { + // GET 요청으로 샘플 데이터를 가져와서 필드 구조 파악 + const result = await this.executeQuery(endpoint, 'GET'); + + if (result.rows.length > 0) { + const sampleRow = result.rows[0]; + return Object.keys(sampleRow).map(key => ({ + column_name: key, + data_type: typeof sampleRow[key], + is_nullable: 'YES', + column_default: null, + description: `${key} 필드` + })); + } + + return []; + } catch (error) { + console.error(`[RestApiConnector] 컬럼 정보 조회 오류 (${endpoint}):`, error); + return []; + } + } + + async getTableColumns(endpoint: string): Promise { + return this.getColumns(endpoint); + } + + // REST API 전용 메서드들 + async getData(endpoint: string, params?: Record): Promise { + const queryString = params ? '?' + new URLSearchParams(params).toString() : ''; + const result = await this.executeQuery(endpoint + queryString, 'GET'); + return result.rows; + } + + async postData(endpoint: string, data: any): Promise { + const result = await this.executeQuery(endpoint, 'POST', data); + return result.rows[0]; + } + + async putData(endpoint: string, data: any): Promise { + const result = await this.executeQuery(endpoint, 'PUT', data); + return result.rows[0]; + } + + async deleteData(endpoint: string): Promise { + const result = await this.executeQuery(endpoint, 'DELETE'); + return result.rows[0]; + } +} diff --git a/backend-node/src/routes/batchExecutionLogRoutes.ts b/backend-node/src/routes/batchExecutionLogRoutes.ts new file mode 100644 index 00000000..8f2dd9ec --- /dev/null +++ b/backend-node/src/routes/batchExecutionLogRoutes.ts @@ -0,0 +1,47 @@ +// 배치 실행 로그 라우트 +// 작성일: 2024-12-24 + +import { Router } from "express"; +import { BatchExecutionLogController } from "../controllers/batchExecutionLogController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +/** + * GET /api/batch-execution-logs + * 배치 실행 로그 목록 조회 + */ +router.get("/", authenticateToken, BatchExecutionLogController.getExecutionLogs); + +/** + * POST /api/batch-execution-logs + * 배치 실행 로그 생성 + */ +router.post("/", authenticateToken, BatchExecutionLogController.createExecutionLog); + +/** + * PUT /api/batch-execution-logs/:id + * 배치 실행 로그 업데이트 + */ +router.put("/:id", authenticateToken, BatchExecutionLogController.updateExecutionLog); + +/** + * DELETE /api/batch-execution-logs/:id + * 배치 실행 로그 삭제 + */ +router.delete("/:id", authenticateToken, BatchExecutionLogController.deleteExecutionLog); + +/** + * GET /api/batch-execution-logs/latest/:batchConfigId + * 특정 배치의 최신 실행 로그 조회 + */ +router.get("/latest/:batchConfigId", authenticateToken, BatchExecutionLogController.getLatestExecutionLog); + +/** + * GET /api/batch-execution-logs/stats + * 배치 실행 통계 조회 + */ +router.get("/stats", authenticateToken, BatchExecutionLogController.getExecutionStats); + +export default router; + diff --git a/backend-node/src/routes/batchManagementRoutes.ts b/backend-node/src/routes/batchManagementRoutes.ts new file mode 100644 index 00000000..d6adf4c5 --- /dev/null +++ b/backend-node/src/routes/batchManagementRoutes.ts @@ -0,0 +1,82 @@ +// 배치관리 전용 라우트 (기존 소스와 완전 분리) +// 작성일: 2024-12-24 + +import { Router } from "express"; +import { BatchManagementController } from "../controllers/batchManagementController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +/** + * GET /api/batch-management/connections + * 사용 가능한 커넥션 목록 조회 + */ +router.get("/connections", authenticateToken, BatchManagementController.getAvailableConnections); + +/** + * GET /api/batch-management/connections/:type/tables + * 내부 DB 테이블 목록 조회 + */ +router.get("/connections/:type/tables", authenticateToken, BatchManagementController.getTablesFromConnection); + +/** + * GET /api/batch-management/connections/:type/:id/tables + * 외부 DB 테이블 목록 조회 + */ +router.get("/connections/:type/:id/tables", authenticateToken, BatchManagementController.getTablesFromConnection); + +/** + * GET /api/batch-management/connections/:type/tables/:tableName/columns + * 내부 DB 테이블 컬럼 정보 조회 + */ +router.get("/connections/:type/tables/:tableName/columns", authenticateToken, BatchManagementController.getTableColumns); + +/** + * GET /api/batch-management/connections/:type/:id/tables/:tableName/columns + * 외부 DB 테이블 컬럼 정보 조회 + */ +router.get("/connections/:type/:id/tables/:tableName/columns", authenticateToken, BatchManagementController.getTableColumns); + +/** + * POST /api/batch-management/batch-configs + * 배치 설정 생성 + */ +router.post("/batch-configs", authenticateToken, BatchManagementController.createBatchConfig); + +/** + * GET /api/batch-management/batch-configs + * 배치 설정 목록 조회 + */ +router.get("/batch-configs", authenticateToken, BatchManagementController.getBatchConfigs); + +/** + * GET /api/batch-management/batch-configs/:id + * 특정 배치 설정 조회 + */ +router.get("/batch-configs/:id", authenticateToken, BatchManagementController.getBatchConfigById); + +/** + * PUT /api/batch-management/batch-configs/:id + * 배치 설정 업데이트 + */ +router.put("/batch-configs/:id", authenticateToken, BatchManagementController.updateBatchConfig); + +/** + * POST /api/batch-management/batch-configs/:id/execute + * 배치 수동 실행 + */ +router.post("/batch-configs/:id/execute", authenticateToken, BatchManagementController.executeBatchConfig); + +/** + * POST /api/batch-management/rest-api/preview + * REST API 데이터 미리보기 + */ +router.post("/rest-api/preview", authenticateToken, BatchManagementController.previewRestApiData); + +/** + * POST /api/batch-management/rest-api/save + * REST API 배치 저장 + */ +router.post("/rest-api/save", authenticateToken, BatchManagementController.saveRestApiBatch); + +export default router; diff --git a/backend-node/src/routes/batchRoutes.ts b/backend-node/src/routes/batchRoutes.ts index 9be9d0ba..c34ee9e5 100644 --- a/backend-node/src/routes/batchRoutes.ts +++ b/backend-node/src/routes/batchRoutes.ts @@ -1,73 +1,70 @@ -// 배치 관리 라우트 -// 작성일: 2024-12-23 +// 배치관리 라우트 +// 작성일: 2024-12-24 -import { Router } from 'express'; -import { BatchController } from '../controllers/batchController'; -import { authenticateToken } from '../middleware/authMiddleware'; +import { Router } from "express"; +import { BatchController } from "../controllers/batchController"; +import { authenticateToken } from "../middleware/authMiddleware"; const router = Router(); -// 모든 라우트에 인증 미들웨어 적용 -router.use(authenticateToken); +/** + * GET /api/batch-configs + * 배치 설정 목록 조회 + */ +router.get("/", authenticateToken, BatchController.getBatchConfigs); /** - * GET /api/batch - * 배치 작업 목록 조회 + * GET /api/batch-configs/connections + * 사용 가능한 커넥션 목록 조회 */ -router.get('/', BatchController.getBatchJobs); +router.get("/connections", BatchController.getAvailableConnections); /** - * GET /api/batch/:id - * 배치 작업 상세 조회 + * GET /api/batch-configs/connections/:type/tables + * 내부 DB 테이블 목록 조회 */ -router.get('/:id', BatchController.getBatchJobById); +router.get("/connections/:type/tables", authenticateToken, BatchController.getTablesFromConnection); /** - * POST /api/batch - * 배치 작업 생성 + * GET /api/batch-configs/connections/:type/:id/tables + * 외부 DB 테이블 목록 조회 */ -router.post('/', BatchController.createBatchJob); +router.get("/connections/:type/:id/tables", authenticateToken, BatchController.getTablesFromConnection); /** - * PUT /api/batch/:id - * 배치 작업 수정 + * GET /api/batch-configs/connections/:type/tables/:tableName/columns + * 내부 DB 테이블 컬럼 정보 조회 */ -router.put('/:id', BatchController.updateBatchJob); +router.get("/connections/:type/tables/:tableName/columns", authenticateToken, BatchController.getTableColumns); /** - * DELETE /api/batch/:id - * 배치 작업 삭제 + * GET /api/batch-configs/connections/:type/:id/tables/:tableName/columns + * 외부 DB 테이블 컬럼 정보 조회 */ -router.delete('/:id', BatchController.deleteBatchJob); +router.get("/connections/:type/:id/tables/:tableName/columns", authenticateToken, BatchController.getTableColumns); /** - * POST /api/batch/:id/execute - * 배치 작업 수동 실행 + * GET /api/batch-configs/:id + * 특정 배치 설정 조회 */ -router.post('/:id/execute', BatchController.executeBatchJob); +router.get("/:id", authenticateToken, BatchController.getBatchConfigById); /** - * GET /api/batch/executions - * 배치 실행 목록 조회 + * POST /api/batch-configs + * 배치 설정 생성 */ -router.get('/executions/list', BatchController.getBatchExecutions); +router.post("/", authenticateToken, BatchController.createBatchConfig); /** - * GET /api/batch/monitoring - * 배치 모니터링 정보 조회 + * PUT /api/batch-configs/:id + * 배치 설정 수정 */ -router.get('/monitoring/status', BatchController.getBatchMonitoring); +router.put("/:id", authenticateToken, BatchController.updateBatchConfig); /** - * GET /api/batch/types/supported - * 지원되는 작업 타입 조회 + * DELETE /api/batch-configs/:id + * 배치 설정 삭제 (논리 삭제) */ -router.get('/types/supported', BatchController.getSupportedJobTypes); +router.delete("/:id", authenticateToken, BatchController.deleteBatchConfig); -/** - * GET /api/batch/schedules/presets - * 스케줄 프리셋 조회 - */ -router.get('/schedules/presets', BatchController.getSchedulePresets); - -export default router; +export default router; \ No newline at end of file diff --git a/backend-node/src/routes/externalDbConnectionRoutes.ts b/backend-node/src/routes/externalDbConnectionRoutes.ts index 858328e1..baeb5f6d 100644 --- a/backend-node/src/routes/externalDbConnectionRoutes.ts +++ b/backend-node/src/routes/externalDbConnectionRoutes.ts @@ -447,17 +447,28 @@ router.get( return res.status(400).json(externalConnections); } - // 외부 커넥션들에 대해 연결 테스트 수행 (병렬 처리) + // 외부 커넥션들에 대해 연결 테스트 수행 (병렬 처리, 타임아웃 5초) const testedConnections = await Promise.all( (externalConnections.data || []).map(async (connection) => { try { - const testResult = - await ExternalDbConnectionService.testConnectionById( - connection.id! - ); + // 개별 연결 테스트에 5초 타임아웃 적용 + const testPromise = ExternalDbConnectionService.testConnectionById( + connection.id! + ); + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error("연결 테스트 타임아웃")), 5000); + }); + + const testResult = await Promise.race([ + testPromise, + timeoutPromise, + ]); return testResult.success ? connection : null; } catch (error) { - console.warn(`커넥션 테스트 실패 (ID: ${connection.id}):`, error); + console.warn( + `커넥션 테스트 실패 (ID: ${connection.id}):`, + error instanceof Error ? error.message : error + ); return null; } }) diff --git a/backend-node/src/routes/fileRoutes.ts b/backend-node/src/routes/fileRoutes.ts index b7b4c975..e62d479a 100644 --- a/backend-node/src/routes/fileRoutes.ts +++ b/backend-node/src/routes/fileRoutes.ts @@ -3,15 +3,26 @@ import { uploadFiles, deleteFile, getFileList, + getComponentFiles, downloadFile, previewFile, getLinkedFiles, uploadMiddleware, + generateTempToken, + getFileByToken, } from "../controllers/fileController"; import { authenticateToken } from "../middleware/authMiddleware"; const router = Router(); +// 공개 접근 라우트 (인증 불필요) +/** + * @route GET /api/files/public/:token + * @desc 임시 토큰으로 파일 접근 (Google Docs Viewer용) + * @access Public + */ +router.get("/public/:token", getFileByToken); + // 모든 파일 API는 인증 필요 router.use(authenticateToken); @@ -30,6 +41,14 @@ router.post("/upload", uploadMiddleware, uploadFiles); */ router.get("/", getFileList); +/** + * @route GET /api/files/component-files + * @desc 컴포넌트의 템플릿 파일과 데이터 파일 모두 조회 + * @query screenId, componentId, tableName, recordId, columnName + * @access Private + */ +router.get("/component-files", getComponentFiles); + /** * @route GET /api/files/linked/:tableName/:recordId * @desc 테이블 연결된 파일 조회 @@ -58,4 +77,11 @@ router.get("/preview/:objid", previewFile); */ router.get("/download/:objid", downloadFile); +/** + * @route POST /api/files/temp-token/:objid + * @desc Google Docs Viewer용 임시 공개 토큰 생성 + * @access Private + */ +router.post("/temp-token/:objid", generateTempToken); + export default router; diff --git a/backend-node/src/routes/multiConnectionRoutes.ts b/backend-node/src/routes/multiConnectionRoutes.ts index 42240596..4a97b9e0 100644 --- a/backend-node/src/routes/multiConnectionRoutes.ts +++ b/backend-node/src/routes/multiConnectionRoutes.ts @@ -51,6 +51,45 @@ router.get( } ); +/** + * GET /api/multi-connection/connections/:connectionId/tables/batch + * 특정 커넥션의 모든 테이블 정보 배치 조회 (컬럼 수 포함) + */ +router.get( + "/connections/:connectionId/tables/batch", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + try { + const connectionId = parseInt(req.params.connectionId); + + if (isNaN(connectionId)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 커넥션 ID입니다.", + }); + } + + logger.info(`배치 테이블 정보 조회 요청: connectionId=${connectionId}`); + + const tables = + await multiConnectionService.getBatchTablesWithColumns(connectionId); + + return res.status(200).json({ + success: true, + data: tables, + message: `커넥션 ${connectionId}의 테이블 정보를 배치 조회했습니다.`, + }); + } catch (error) { + logger.error(`배치 테이블 정보 조회 실패: ${error}`); + return res.status(500).json({ + success: false, + message: "배치 테이블 정보 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } +); + /** * GET /api/multi-connection/connections/:connectionId/tables/:tableName/columns * 특정 커넥션의 테이블 컬럼 정보 조회 (메인 DB 포함) diff --git a/backend-node/src/services/adminService.ts b/backend-node/src/services/adminService.ts index d5f8c46a..ddfd8cbc 100644 --- a/backend-node/src/services/adminService.ts +++ b/backend-node/src/services/adminService.ts @@ -1,7 +1,7 @@ -import { PrismaClient } from "@prisma/client"; import { logger } from "../utils/logger"; -const prisma = new PrismaClient(); +// 🔧 Prisma 클라이언트 중복 생성 방지 - 기존 인스턴스 재사용 +import prisma = require("../config/database"); export class AdminService { /** diff --git a/backend-node/src/services/authService.ts b/backend-node/src/services/authService.ts index b5e7f0bb..1502b97f 100644 --- a/backend-node/src/services/authService.ts +++ b/backend-node/src/services/authService.ts @@ -186,17 +186,17 @@ export class AuthService { }); // DB에서 조회한 원본 사용자 정보 상세 로그 - console.log("🔍 AuthService - DB 원본 사용자 정보:", { - userId: userInfo.user_id, - company_code: userInfo.company_code, - company_code_type: typeof userInfo.company_code, - company_code_is_null: userInfo.company_code === null, - company_code_is_undefined: userInfo.company_code === undefined, - company_code_is_empty: userInfo.company_code === "", - dept_code: userInfo.dept_code, - allUserFields: Object.keys(userInfo), - companyInfo: companyInfo?.company_name, - }); + //console.log("🔍 AuthService - DB 원본 사용자 정보:", { + // userId: userInfo.user_id, + // company_code: userInfo.company_code, + // company_code_type: typeof userInfo.company_code, + // company_code_is_null: userInfo.company_code === null, + // company_code_is_undefined: userInfo.company_code === undefined, + // company_code_is_empty: userInfo.company_code === "", + // dept_code: userInfo.dept_code, + // allUserFields: Object.keys(userInfo), + // companyInfo: companyInfo?.company_name, + //}); // PersonBean 형태로 변환 (null 값을 undefined로 변환) const personBean: PersonBean = { @@ -217,16 +217,16 @@ export class AuthService { authName: authNames || undefined, companyCode: userInfo.company_code || "ILSHIN", photo: userInfo.photo - ? `data:image/jpeg;base64,${userInfo.photo.toString("base64")}` + ? `data:image/jpeg;base64,${Buffer.from(userInfo.photo).toString("base64")}` : undefined, locale: userInfo.locale || "KR", }; - console.log("📦 AuthService - 최종 PersonBean:", { - userId: personBean.userId, - companyCode: personBean.companyCode, - deptCode: personBean.deptCode, - }); + //console.log("📦 AuthService - 최종 PersonBean:", { + // userId: personBean.userId, + // companyCode: personBean.companyCode, + // deptCode: personBean.deptCode, + //}); logger.info(`사용자 정보 조회 완료: ${userId}`); return personBean; diff --git a/backend-node/src/services/batchExecutionLogService.ts b/backend-node/src/services/batchExecutionLogService.ts new file mode 100644 index 00000000..2fee555a --- /dev/null +++ b/backend-node/src/services/batchExecutionLogService.ts @@ -0,0 +1,299 @@ +// 배치 실행 로그 서비스 +// 작성일: 2024-12-24 + +import prisma from "../config/database"; +import { + BatchExecutionLog, + CreateBatchExecutionLogRequest, + UpdateBatchExecutionLogRequest, + BatchExecutionLogFilter, + BatchExecutionLogWithConfig +} from "../types/batchExecutionLogTypes"; +import { ApiResponse } from "../types/batchTypes"; + +export class BatchExecutionLogService { + /** + * 배치 실행 로그 목록 조회 + */ + static async getExecutionLogs( + filter: BatchExecutionLogFilter = {} + ): Promise> { + try { + const { + batch_config_id, + execution_status, + start_date, + end_date, + page = 1, + limit = 50 + } = filter; + + const skip = (page - 1) * limit; + const take = limit; + + // WHERE 조건 구성 + const where: any = {}; + + if (batch_config_id) { + where.batch_config_id = batch_config_id; + } + + if (execution_status) { + where.execution_status = execution_status; + } + + if (start_date || end_date) { + where.start_time = {}; + if (start_date) { + where.start_time.gte = start_date; + } + if (end_date) { + where.start_time.lte = end_date; + } + } + + // 로그 조회 + const [logs, total] = await Promise.all([ + prisma.batch_execution_logs.findMany({ + where, + include: { + batch_config: { + select: { + id: true, + batch_name: true, + description: true, + cron_schedule: true, + is_active: true + } + } + }, + orderBy: { start_time: 'desc' }, + skip, + take + }), + prisma.batch_execution_logs.count({ where }) + ]); + + return { + success: true, + data: logs as BatchExecutionLogWithConfig[], + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit) + } + }; + } catch (error) { + console.error("배치 실행 로그 조회 실패:", error); + return { + success: false, + message: "배치 실행 로그 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * 배치 실행 로그 생성 + */ + static async createExecutionLog( + data: CreateBatchExecutionLogRequest + ): Promise> { + try { + const log = await prisma.batch_execution_logs.create({ + data: { + batch_config_id: data.batch_config_id, + execution_status: data.execution_status, + start_time: data.start_time || new Date(), + end_time: data.end_time, + duration_ms: data.duration_ms, + total_records: data.total_records || 0, + success_records: data.success_records || 0, + failed_records: data.failed_records || 0, + error_message: data.error_message, + error_details: data.error_details, + server_name: data.server_name || process.env.HOSTNAME || 'unknown', + process_id: data.process_id || process.pid?.toString() + } + }); + + return { + success: true, + data: log as BatchExecutionLog, + message: "배치 실행 로그가 생성되었습니다." + }; + } catch (error) { + console.error("배치 실행 로그 생성 실패:", error); + return { + success: false, + message: "배치 실행 로그 생성 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * 배치 실행 로그 업데이트 + */ + static async updateExecutionLog( + id: number, + data: UpdateBatchExecutionLogRequest + ): Promise> { + try { + const log = await prisma.batch_execution_logs.update({ + where: { id }, + data: { + execution_status: data.execution_status, + end_time: data.end_time, + duration_ms: data.duration_ms, + total_records: data.total_records, + success_records: data.success_records, + failed_records: data.failed_records, + error_message: data.error_message, + error_details: data.error_details + } + }); + + return { + success: true, + data: log as BatchExecutionLog, + message: "배치 실행 로그가 업데이트되었습니다." + }; + } catch (error) { + console.error("배치 실행 로그 업데이트 실패:", error); + return { + success: false, + message: "배치 실행 로그 업데이트 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * 배치 실행 로그 삭제 + */ + static async deleteExecutionLog(id: number): Promise> { + try { + await prisma.batch_execution_logs.delete({ + where: { id } + }); + + return { + success: true, + message: "배치 실행 로그가 삭제되었습니다." + }; + } catch (error) { + console.error("배치 실행 로그 삭제 실패:", error); + return { + success: false, + message: "배치 실행 로그 삭제 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * 특정 배치의 최신 실행 로그 조회 + */ + static async getLatestExecutionLog( + batchConfigId: number + ): Promise> { + try { + const log = await prisma.batch_execution_logs.findFirst({ + where: { batch_config_id: batchConfigId }, + orderBy: { start_time: 'desc' } + }); + + return { + success: true, + data: log as BatchExecutionLog | null + }; + } catch (error) { + console.error("최신 배치 실행 로그 조회 실패:", error); + return { + success: false, + message: "최신 배치 실행 로그 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * 배치 실행 통계 조회 + */ + static async getExecutionStats( + batchConfigId?: number, + startDate?: Date, + endDate?: Date + ): Promise> { + try { + const where: any = {}; + + if (batchConfigId) { + where.batch_config_id = batchConfigId; + } + + if (startDate || endDate) { + where.start_time = {}; + if (startDate) { + where.start_time.gte = startDate; + } + if (endDate) { + where.start_time.lte = endDate; + } + } + + const logs = await prisma.batch_execution_logs.findMany({ + where, + select: { + execution_status: true, + duration_ms: true, + total_records: true + } + }); + + const total_executions = logs.length; + const success_count = logs.filter((log: any) => log.execution_status === 'SUCCESS').length; + const failed_count = logs.filter((log: any) => log.execution_status === 'FAILED').length; + const success_rate = total_executions > 0 ? (success_count / total_executions) * 100 : 0; + + const validDurations = logs + .filter((log: any) => log.duration_ms !== null) + .map((log: any) => log.duration_ms!); + const average_duration_ms = validDurations.length > 0 + ? validDurations.reduce((sum: number, duration: number) => sum + duration, 0) / validDurations.length + : 0; + + const total_records_processed = logs + .filter((log: any) => log.total_records !== null) + .reduce((sum: number, log: any) => sum + (log.total_records || 0), 0); + + return { + success: true, + data: { + total_executions, + success_count, + failed_count, + success_rate, + average_duration_ms, + total_records_processed + } + }; + } catch (error) { + console.error("배치 실행 통계 조회 실패:", error); + return { + success: false, + message: "배치 실행 통계 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } +} diff --git a/backend-node/src/services/batchExternalDbService.ts b/backend-node/src/services/batchExternalDbService.ts new file mode 100644 index 00000000..470c3b75 --- /dev/null +++ b/backend-node/src/services/batchExternalDbService.ts @@ -0,0 +1,912 @@ +// 배치관리 전용 외부 DB 서비스 +// 기존 ExternalDbConnectionService와 분리하여 배치관리 시스템에 특화된 기능 제공 +// 작성일: 2024-12-24 + +import prisma from "../config/database"; +import { PasswordEncryption } from "../utils/passwordEncryption"; +import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory"; +import { RestApiConnector } from "../database/RestApiConnector"; +import { ApiResponse, ColumnInfo, TableInfo } from "../types/batchTypes"; + +export class BatchExternalDbService { + /** + * 배치관리용 외부 DB 연결 목록 조회 + */ + static async getAvailableConnections(): Promise>> { + try { + const connections: Array<{ + type: 'internal' | 'external'; + id?: number; + name: string; + db_type?: string; + }> = []; + + // 내부 DB 추가 + connections.push({ + type: 'internal', + name: '내부 데이터베이스 (PostgreSQL)', + db_type: 'postgresql' + }); + + // 활성화된 외부 DB 연결 조회 + const externalConnections = await prisma.external_db_connections.findMany({ + where: { is_active: 'Y' }, + select: { + id: true, + connection_name: true, + db_type: true, + description: true + }, + orderBy: { connection_name: 'asc' } + }); + + // 외부 DB 연결 추가 + externalConnections.forEach(conn => { + connections.push({ + type: 'external', + id: conn.id, + name: `${conn.connection_name} (${conn.db_type?.toUpperCase()})`, + db_type: conn.db_type || undefined + }); + }); + + return { + success: true, + data: connections, + message: `${connections.length}개의 연결을 조회했습니다.` + }; + } catch (error) { + console.error("배치관리 연결 목록 조회 실패:", error); + return { + success: false, + message: "연결 목록 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * 배치관리용 테이블 목록 조회 + */ + static async getTablesFromConnection( + connectionType: 'internal' | 'external', + connectionId?: number + ): Promise> { + try { + let tables: TableInfo[] = []; + + if (connectionType === 'internal') { + // 내부 DB 테이블 조회 + const result = await prisma.$queryRaw>` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_type = 'BASE TABLE' + ORDER BY table_name + `; + + tables = result.map(row => ({ + table_name: row.table_name, + columns: [] + })); + } else if (connectionType === 'external' && connectionId) { + // 외부 DB 테이블 조회 + const tablesResult = await this.getExternalTables(connectionId); + if (tablesResult.success && tablesResult.data) { + tables = tablesResult.data; + } + } + + return { + success: true, + data: tables, + message: `${tables.length}개의 테이블을 조회했습니다.` + }; + } catch (error) { + console.error("배치관리 테이블 목록 조회 실패:", error); + return { + success: false, + message: "테이블 목록 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * 배치관리용 테이블 컬럼 정보 조회 + */ + static async getTableColumns( + connectionType: 'internal' | 'external', + connectionId: number | undefined, + tableName: string + ): Promise> { + try { + console.log(`[BatchExternalDbService] getTableColumns 호출:`, { + connectionType, + connectionId, + tableName + }); + + let columns: ColumnInfo[] = []; + + if (connectionType === 'internal') { + // 내부 DB 컬럼 조회 + console.log(`[BatchExternalDbService] 내부 DB 컬럼 조회 시작: ${tableName}`); + + const result = await prisma.$queryRaw>` + SELECT + column_name, + data_type, + is_nullable, + column_default + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = ${tableName} + ORDER BY ordinal_position + `; + + console.log(`[BatchExternalDbService] 내부 DB 컬럼 조회 결과:`, result); + + columns = result.map(row => ({ + column_name: row.column_name, + data_type: row.data_type, + is_nullable: row.is_nullable, + column_default: row.column_default, + })); + } else if (connectionType === 'external' && connectionId) { + // 외부 DB 컬럼 조회 + console.log(`[BatchExternalDbService] 외부 DB 컬럼 조회 시작: connectionId=${connectionId}, tableName=${tableName}`); + + const columnsResult = await this.getExternalTableColumns(connectionId, tableName); + + console.log(`[BatchExternalDbService] 외부 DB 컬럼 조회 결과:`, columnsResult); + + if (columnsResult.success && columnsResult.data) { + columns = columnsResult.data; + } + } + + console.log(`[BatchExternalDbService] 최종 컬럼 목록:`, columns); + return { + success: true, + data: columns, + message: `${columns.length}개의 컬럼을 조회했습니다.` + }; + } catch (error) { + console.error("[BatchExternalDbService] 컬럼 정보 조회 오류:", error); + return { + success: false, + message: "컬럼 정보 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * 외부 DB 테이블 목록 조회 (내부 구현) + */ + private static async getExternalTables(connectionId: number): Promise> { + try { + // 연결 정보 조회 + const connection = await prisma.external_db_connections.findUnique({ + where: { id: connectionId } + }); + + if (!connection) { + return { + success: false, + message: "연결 정보를 찾을 수 없습니다." + }; + } + + // 비밀번호 복호화 + const decryptedPassword = PasswordEncryption.decrypt(connection.password); + if (!decryptedPassword) { + return { + success: false, + message: "비밀번호 복호화에 실패했습니다." + }; + } + + // 연결 설정 준비 + const config = { + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username, + password: decryptedPassword, + connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined, + queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined, + ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false + }; + + // DatabaseConnectorFactory를 통한 테이블 목록 조회 + const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, connectionId); + const tables = await connector.getTables(); + + return { + success: true, + message: "테이블 목록을 조회했습니다.", + data: tables + }; + } catch (error) { + console.error("외부 DB 테이블 목록 조회 오류:", error); + return { + success: false, + message: "테이블 목록 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * 외부 DB 테이블 컬럼 정보 조회 (내부 구현) + */ + private static async getExternalTableColumns(connectionId: number, tableName: string): Promise> { + try { + console.log(`[BatchExternalDbService] getExternalTableColumns 호출: connectionId=${connectionId}, tableName=${tableName}`); + + // 연결 정보 조회 + const connection = await prisma.external_db_connections.findUnique({ + where: { id: connectionId } + }); + + if (!connection) { + console.log(`[BatchExternalDbService] 연결 정보를 찾을 수 없음: connectionId=${connectionId}`); + return { + success: false, + message: "연결 정보를 찾을 수 없습니다." + }; + } + + console.log(`[BatchExternalDbService] 연결 정보 조회 성공:`, { + id: connection.id, + connection_name: connection.connection_name, + db_type: connection.db_type, + host: connection.host, + port: connection.port, + database_name: connection.database_name + }); + + // 비밀번호 복호화 + const decryptedPassword = PasswordEncryption.decrypt(connection.password); + + // 연결 설정 준비 + const config = { + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username, + password: decryptedPassword, + connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined, + queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined, + ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false + }; + + console.log(`[BatchExternalDbService] 커넥터 생성 시작: db_type=${connection.db_type}`); + + // 데이터베이스 타입에 따른 커넥터 생성 + const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, connectionId); + + console.log(`[BatchExternalDbService] 커넥터 생성 완료, 컬럼 조회 시작: tableName=${tableName}`); + + // 컬럼 정보 조회 + console.log(`[BatchExternalDbService] connector.getColumns 호출 전`); + const columns = await connector.getColumns(tableName); + + console.log(`[BatchExternalDbService] 원본 컬럼 조회 결과:`, columns); + console.log(`[BatchExternalDbService] 원본 컬럼 개수:`, columns ? columns.length : 'null/undefined'); + + // 각 데이터베이스 커넥터의 반환 구조가 다르므로 통일된 구조로 변환 + const standardizedColumns: ColumnInfo[] = columns.map((col: any) => { + console.log(`[BatchExternalDbService] 컬럼 변환 중:`, col); + + // MySQL/MariaDB 구조: {name, dataType, isNullable, defaultValue} (MySQLConnector만) + if (col.name && col.dataType !== undefined) { + const result = { + column_name: col.name, + data_type: col.dataType, + is_nullable: col.isNullable ? 'YES' : 'NO', + column_default: col.defaultValue || null, + }; + console.log(`[BatchExternalDbService] MySQL/MariaDB 구조로 변환:`, result); + return result; + } + // PostgreSQL/Oracle/MSSQL/MariaDB 구조: {column_name, data_type, is_nullable, column_default} + else { + const result = { + column_name: col.column_name || col.COLUMN_NAME, + data_type: col.data_type || col.DATA_TYPE, + is_nullable: col.is_nullable || col.IS_NULLABLE || (col.nullable === 'Y' ? 'YES' : 'NO'), + column_default: col.column_default || col.COLUMN_DEFAULT || null, + }; + console.log(`[BatchExternalDbService] 표준 구조로 변환:`, result); + return result; + } + }); + + console.log(`[BatchExternalDbService] 표준화된 컬럼 목록:`, standardizedColumns); + + // 빈 배열인 경우 경고 로그 + if (!standardizedColumns || standardizedColumns.length === 0) { + console.warn(`[BatchExternalDbService] 컬럼이 비어있음: connectionId=${connectionId}, tableName=${tableName}`); + console.warn(`[BatchExternalDbService] 연결 정보:`, { + db_type: connection.db_type, + host: connection.host, + port: connection.port, + database_name: connection.database_name, + username: connection.username + }); + + // 테이블 존재 여부 확인 + console.warn(`[BatchExternalDbService] 테이블 존재 여부 확인을 위해 테이블 목록 조회 시도`); + try { + const tables = await connector.getTables(); + console.warn(`[BatchExternalDbService] 사용 가능한 테이블 목록:`, tables.map(t => t.table_name)); + + // 테이블명이 정확한지 확인 + const tableExists = tables.some(t => t.table_name.toLowerCase() === tableName.toLowerCase()); + console.warn(`[BatchExternalDbService] 테이블 존재 여부: ${tableExists}`); + + // 정확한 테이블명 찾기 + const exactTable = tables.find(t => t.table_name.toLowerCase() === tableName.toLowerCase()); + if (exactTable) { + console.warn(`[BatchExternalDbService] 정확한 테이블명: ${exactTable.table_name}`); + } + + // 모든 테이블명 출력 + console.warn(`[BatchExternalDbService] 모든 테이블명:`, tables.map(t => `"${t.table_name}"`)); + + // 테이블명 비교 + console.warn(`[BatchExternalDbService] 요청된 테이블명: "${tableName}"`); + console.warn(`[BatchExternalDbService] 테이블명 비교 결과:`, tables.map(t => ({ + table_name: t.table_name, + matches: t.table_name.toLowerCase() === tableName.toLowerCase(), + exact_match: t.table_name === tableName + }))); + + // 정확한 테이블명으로 다시 시도 + if (exactTable && exactTable.table_name !== tableName) { + console.warn(`[BatchExternalDbService] 정확한 테이블명으로 다시 시도: ${exactTable.table_name}`); + try { + const correctColumns = await connector.getColumns(exactTable.table_name); + console.warn(`[BatchExternalDbService] 정확한 테이블명으로 조회한 컬럼:`, correctColumns); + } catch (correctError) { + console.error(`[BatchExternalDbService] 정확한 테이블명으로 조회 실패:`, correctError); + } + } + } catch (tableError) { + console.error(`[BatchExternalDbService] 테이블 목록 조회 실패:`, tableError); + } + } + + return { + success: true, + data: standardizedColumns, + message: "컬럼 정보를 조회했습니다." + }; + } catch (error) { + console.error("[BatchExternalDbService] 외부 DB 컬럼 정보 조회 오류:", error); + console.error("[BatchExternalDbService] 오류 스택:", error instanceof Error ? error.stack : 'No stack trace'); + return { + success: false, + message: "컬럼 정보 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * 외부 DB 테이블에서 데이터 조회 + */ + static async getDataFromTable( + connectionId: number, + tableName: string, + limit: number = 100 + ): Promise> { + try { + console.log(`[BatchExternalDbService] 외부 DB 데이터 조회: connectionId=${connectionId}, tableName=${tableName}`); + + // 외부 DB 연결 정보 조회 + const connection = await prisma.external_db_connections.findUnique({ + where: { id: connectionId } + }); + + if (!connection) { + return { + success: false, + message: "외부 DB 연결을 찾을 수 없습니다." + }; + } + + // 패스워드 복호화 + const decryptedPassword = PasswordEncryption.decrypt(connection.password); + + // DB 연결 설정 + const config = { + host: connection.host, + port: connection.port, + user: connection.username, + password: decryptedPassword, + database: connection.database_name, + }; + + // DB 커넥터 생성 + const connector = await DatabaseConnectorFactory.createConnector( + connection.db_type || 'postgresql', + config, + connectionId + ); + + // 데이터 조회 (DB 타입에 따라 쿼리 구문 변경) + let query: string; + const dbType = connection.db_type?.toLowerCase() || 'postgresql'; + + if (dbType === 'oracle') { + query = `SELECT * FROM ${tableName} WHERE ROWNUM <= ${limit}`; + } else { + query = `SELECT * FROM ${tableName} LIMIT ${limit}`; + } + + console.log(`[BatchExternalDbService] 실행할 쿼리: ${query}`); + const result = await connector.executeQuery(query); + + console.log(`[BatchExternalDbService] 외부 DB 데이터 조회 완료: ${result.rows.length}개 레코드`); + + return { + success: true, + data: result.rows + }; + } catch (error) { + console.error(`외부 DB 데이터 조회 오류 (connectionId: ${connectionId}, table: ${tableName}):`, error); + return { + success: false, + message: "외부 DB 데이터 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * 외부 DB 테이블에서 특정 컬럼들만 조회 + */ + static async getDataFromTableWithColumns( + connectionId: number, + tableName: string, + columns: string[], + limit: number = 100 + ): Promise> { + try { + console.log(`[BatchExternalDbService] 외부 DB 특정 컬럼 조회: connectionId=${connectionId}, tableName=${tableName}, columns=[${columns.join(', ')}]`); + + // 외부 DB 연결 정보 조회 + const connection = await prisma.external_db_connections.findUnique({ + where: { id: connectionId } + }); + + if (!connection) { + return { + success: false, + message: "외부 DB 연결을 찾을 수 없습니다." + }; + } + + // 패스워드 복호화 + const decryptedPassword = PasswordEncryption.decrypt(connection.password); + + // DB 연결 설정 + const config = { + host: connection.host, + port: connection.port, + user: connection.username, + password: decryptedPassword, + database: connection.database_name, + }; + + // DB 커넥터 생성 + const connector = await DatabaseConnectorFactory.createConnector( + connection.db_type || 'postgresql', + config, + connectionId + ); + + // 데이터 조회 (DB 타입에 따라 쿼리 구문 변경) + let query: string; + const dbType = connection.db_type?.toLowerCase() || 'postgresql'; + const columnList = columns.join(', '); + + if (dbType === 'oracle') { + query = `SELECT ${columnList} FROM ${tableName} WHERE ROWNUM <= ${limit}`; + } else { + query = `SELECT ${columnList} FROM ${tableName} LIMIT ${limit}`; + } + + console.log(`[BatchExternalDbService] 실행할 쿼리: ${query}`); + const result = await connector.executeQuery(query); + + console.log(`[BatchExternalDbService] 외부 DB 특정 컬럼 조회 완료: ${result.rows.length}개 레코드`); + + return { + success: true, + data: result.rows + }; + } catch (error) { + console.error(`외부 DB 특정 컬럼 조회 오류 (connectionId: ${connectionId}, table: ${tableName}):`, error); + return { + success: false, + message: "외부 DB 특정 컬럼 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * 외부 DB 테이블에 데이터 삽입 + */ + static async insertDataToTable( + connectionId: number, + tableName: string, + data: any[] + ): Promise> { + try { + console.log(`[BatchExternalDbService] 외부 DB 데이터 삽입: connectionId=${connectionId}, tableName=${tableName}, ${data.length}개 레코드`); + + if (!data || data.length === 0) { + return { + success: true, + data: { successCount: 0, failedCount: 0 } + }; + } + + // 외부 DB 연결 정보 조회 + const connection = await prisma.external_db_connections.findUnique({ + where: { id: connectionId } + }); + + if (!connection) { + return { + success: false, + message: "외부 DB 연결을 찾을 수 없습니다." + }; + } + + // 패스워드 복호화 + const decryptedPassword = PasswordEncryption.decrypt(connection.password); + + // DB 연결 설정 + const config = { + host: connection.host, + port: connection.port, + user: connection.username, + password: decryptedPassword, + database: connection.database_name, + }; + + // DB 커넥터 생성 + const connector = await DatabaseConnectorFactory.createConnector( + connection.db_type || 'postgresql', + config, + connectionId + ); + + let successCount = 0; + let failedCount = 0; + + // 각 레코드를 개별적으로 삽입 (UPSERT 방식으로 중복 처리) + for (const record of data) { + try { + const columns = Object.keys(record); + const values = Object.values(record); + + // 값들을 SQL 문자열로 변환 (타입별 처리) + const formattedValues = values.map(value => { + if (value === null || value === undefined) { + return 'NULL'; + } else if (value instanceof Date) { + // Date 객체를 MySQL/MariaDB 형식으로 변환 + return `'${value.toISOString().slice(0, 19).replace('T', ' ')}'`; + } else if (typeof value === 'string') { + // 문자열이 날짜 형식인지 확인 + const dateRegex = /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{2}\s+\d{4}\s+\d{2}:\d{2}:\d{2}/; + if (dateRegex.test(value)) { + // JavaScript Date 문자열을 MySQL 형식으로 변환 + const date = new Date(value); + return `'${date.toISOString().slice(0, 19).replace('T', ' ')}'`; + } else { + return `'${value.replace(/'/g, "''")}'`; // SQL 인젝션 방지를 위한 간단한 이스케이프 + } + } else if (typeof value === 'number') { + return String(value); + } else if (typeof value === 'boolean') { + return value ? '1' : '0'; + } else { + // 기타 객체는 문자열로 변환 + return `'${String(value).replace(/'/g, "''")}'`; + } + }).join(', '); + + // Primary Key 컬럼 추정 + const primaryKeyColumn = columns.includes('id') ? 'id' : + columns.includes('user_id') ? 'user_id' : + columns[0]; + + // UPDATE SET 절 생성 (Primary Key 제외) + const updateColumns = columns.filter(col => col !== primaryKeyColumn); + + let query: string; + const dbType = connection.db_type?.toLowerCase() || 'mysql'; + + if (dbType === 'mysql' || dbType === 'mariadb') { + // MySQL/MariaDB: ON DUPLICATE KEY UPDATE 사용 + if (updateColumns.length > 0) { + const updateSet = updateColumns.map(col => `${col} = VALUES(${col})`).join(', '); + query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${formattedValues}) + ON DUPLICATE KEY UPDATE ${updateSet}`; + } else { + // Primary Key만 있는 경우 IGNORE 사용 + query = `INSERT IGNORE INTO ${tableName} (${columns.join(', ')}) VALUES (${formattedValues})`; + } + } else { + // 다른 DB는 기본 INSERT 사용 + query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${formattedValues})`; + } + + console.log(`[BatchExternalDbService] 실행할 쿼리: ${query}`); + console.log(`[BatchExternalDbService] 삽입할 데이터:`, record); + + await connector.executeQuery(query); + successCount++; + } catch (error) { + console.error(`외부 DB 레코드 UPSERT 실패:`, error); + failedCount++; + } + } + + console.log(`[BatchExternalDbService] 외부 DB 데이터 삽입 완료: 성공 ${successCount}개, 실패 ${failedCount}개`); + + return { + success: true, + data: { successCount, failedCount } + }; + } catch (error) { + console.error(`외부 DB 데이터 삽입 오류 (connectionId: ${connectionId}, table: ${tableName}):`, error); + return { + success: false, + message: "외부 DB 데이터 삽입 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * REST API에서 데이터 조회 + */ + static async getDataFromRestApi( + apiUrl: string, + apiKey: string, + endpoint: string, + method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET', + columns?: string[], + limit: number = 100 + ): Promise> { + try { + console.log(`[BatchExternalDbService] REST API 데이터 조회: ${apiUrl}${endpoint}`); + + // REST API 커넥터 생성 + const connector = new RestApiConnector({ + baseUrl: apiUrl, + apiKey: apiKey, + timeout: 30000 + }); + + // 연결 테스트 + await connector.connect(); + + // 데이터 조회 + const result = await connector.executeQuery(endpoint, method); + let data = result.rows; + + // 컬럼 필터링 (지정된 컬럼만 추출) + if (columns && columns.length > 0) { + data = data.map(row => { + const filteredRow: any = {}; + columns.forEach(col => { + if (row.hasOwnProperty(col)) { + filteredRow[col] = row[col]; + } + }); + return filteredRow; + }); + } + + // 제한 개수 적용 + if (limit > 0) { + data = data.slice(0, limit); + } + + console.log(`[BatchExternalDbService] REST API 데이터 조회 완료: ${data.length}개 레코드`); + + return { + success: true, + data: data + }; + } catch (error) { + console.error(`[BatchExternalDbService] REST API 데이터 조회 오류 (${apiUrl}${endpoint}):`, error); + return { + success: false, + message: "REST API 데이터 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * 템플릿 기반 REST API로 데이터 전송 (DB → REST API 배치용) + */ + static async sendDataToRestApiWithTemplate( + apiUrl: string, + apiKey: string, + endpoint: string, + method: 'POST' | 'PUT' | 'DELETE' = 'POST', + templateBody: string, + data: any[], + urlPathColumn?: string // URL 경로에 사용할 컬럼명 (PUT/DELETE용) + ): Promise> { + try { + console.log(`[BatchExternalDbService] 템플릿 기반 REST API 데이터 전송: ${apiUrl}${endpoint}, ${data.length}개 레코드`); + console.log(`[BatchExternalDbService] Request Body 템플릿:`, templateBody); + + // REST API 커넥터 생성 + const connector = new RestApiConnector({ + baseUrl: apiUrl, + apiKey: apiKey, + timeout: 30000 + }); + + // 연결 테스트 + await connector.connect(); + + let successCount = 0; + let failedCount = 0; + + // 각 레코드를 개별적으로 전송 + for (const record of data) { + try { + // 템플릿 처리: {{컬럼명}} → 실제 값으로 치환 + let processedBody = templateBody; + for (const [key, value] of Object.entries(record)) { + const placeholder = `{{${key}}}`; + let stringValue = ''; + + if (value !== null && value !== undefined) { + // Date 객체인 경우 다양한 포맷으로 변환 + if (value instanceof Date) { + // ISO 형식: 2025-09-25T07:22:52.000Z + stringValue = value.toISOString(); + + // 다른 포맷이 필요한 경우 여기서 처리 + // 예: YYYY-MM-DD 형식 + // stringValue = value.toISOString().split('T')[0]; + + // 예: YYYY-MM-DD HH:mm:ss 형식 + // stringValue = value.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ''); + } else { + stringValue = String(value); + } + } + + processedBody = processedBody.replace(new RegExp(placeholder.replace(/[{}]/g, '\\$&'), 'g'), stringValue); + } + + console.log(`[BatchExternalDbService] 원본 레코드:`, record); + console.log(`[BatchExternalDbService] 처리된 Request Body:`, processedBody); + + // JSON 파싱하여 객체로 변환 + let requestData; + try { + requestData = JSON.parse(processedBody); + } catch (parseError) { + console.error(`[BatchExternalDbService] JSON 파싱 오류:`, parseError); + throw new Error(`Request Body JSON 파싱 실패: ${parseError}`); + } + + // URL 경로 파라미터 처리 (PUT/DELETE용) + let finalEndpoint = endpoint; + if ((method === 'PUT' || method === 'DELETE') && urlPathColumn && record[urlPathColumn]) { + // /api/users → /api/users/user123 + finalEndpoint = `${endpoint}/${record[urlPathColumn]}`; + } + + console.log(`[BatchExternalDbService] 실행할 API 호출: ${method} ${finalEndpoint}`); + console.log(`[BatchExternalDbService] 전송할 데이터:`, requestData); + + await connector.executeQuery(finalEndpoint, method, requestData); + successCount++; + } catch (error) { + console.error(`REST API 레코드 전송 실패:`, error); + failedCount++; + } + } + + console.log(`[BatchExternalDbService] 템플릿 기반 REST API 데이터 전송 완료: 성공 ${successCount}개, 실패 ${failedCount}개`); + + return { + success: true, + data: { successCount, failedCount } + }; + } catch (error) { + console.error(`[BatchExternalDbService] 템플릿 기반 REST API 데이터 전송 오류:`, error); + return { + success: false, + message: `REST API 데이터 전송 실패: ${error}`, + data: { successCount: 0, failedCount: 0 } + }; + } + } + + /** + * REST API로 데이터 전송 (기존 메서드) + */ + static async sendDataToRestApi( + apiUrl: string, + apiKey: string, + endpoint: string, + method: 'POST' | 'PUT' = 'POST', + data: any[] + ): Promise> { + try { + console.log(`[BatchExternalDbService] REST API 데이터 전송: ${apiUrl}${endpoint}, ${data.length}개 레코드`); + + // REST API 커넥터 생성 + const connector = new RestApiConnector({ + baseUrl: apiUrl, + apiKey: apiKey, + timeout: 30000 + }); + + // 연결 테스트 + await connector.connect(); + + let successCount = 0; + let failedCount = 0; + + // 각 레코드를 개별적으로 전송 + for (const record of data) { + try { + console.log(`[BatchExternalDbService] 실행할 API 호출: ${method} ${endpoint}`); + console.log(`[BatchExternalDbService] 전송할 데이터:`, record); + + await connector.executeQuery(endpoint, method, record); + successCount++; + } catch (error) { + console.error(`REST API 레코드 전송 실패:`, error); + failedCount++; + } + } + + console.log(`[BatchExternalDbService] REST API 데이터 전송 완료: 성공 ${successCount}개, 실패 ${failedCount}개`); + + return { + success: true, + data: { successCount, failedCount } + }; + } catch (error) { + console.error(`[BatchExternalDbService] REST API 데이터 전송 오류 (${apiUrl}${endpoint}):`, error); + return { + success: false, + message: "REST API 데이터 전송 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } +} diff --git a/backend-node/src/services/batchManagementService.ts b/backend-node/src/services/batchManagementService.ts new file mode 100644 index 00000000..1b082209 --- /dev/null +++ b/backend-node/src/services/batchManagementService.ts @@ -0,0 +1,373 @@ +// 배치관리 전용 서비스 (기존 소스와 완전 분리) +// 작성일: 2024-12-24 + +import prisma from "../config/database"; +import { PasswordEncryption } from "../utils/passwordEncryption"; +import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory"; + +// 배치관리 전용 타입 정의 +export interface BatchConnectionInfo { + type: 'internal' | 'external'; + id?: number; + name: string; + db_type?: string; +} + +export interface BatchTableInfo { + table_name: string; + columns: BatchColumnInfo[]; + description?: string | null; +} + +export interface BatchColumnInfo { + column_name: string; + data_type: string; + is_nullable?: string; + column_default?: string | null; +} + +export interface BatchApiResponse { + success: boolean; + data?: T; + message?: string; + error?: string; +} + +export class BatchManagementService { + /** + * 배치관리용 연결 목록 조회 + */ + static async getAvailableConnections(): Promise> { + try { + const connections: BatchConnectionInfo[] = []; + + // 내부 DB 추가 + connections.push({ + type: 'internal', + name: '내부 데이터베이스 (PostgreSQL)', + db_type: 'postgresql' + }); + + // 활성화된 외부 DB 연결 조회 + const externalConnections = await prisma.external_db_connections.findMany({ + where: { is_active: 'Y' }, + select: { + id: true, + connection_name: true, + db_type: true, + description: true + }, + orderBy: { connection_name: 'asc' } + }); + + // 외부 DB 연결 추가 + externalConnections.forEach(conn => { + connections.push({ + type: 'external', + id: conn.id, + name: `${conn.connection_name} (${conn.db_type?.toUpperCase()})`, + db_type: conn.db_type || undefined + }); + }); + + return { + success: true, + data: connections, + message: `${connections.length}개의 연결을 조회했습니다.` + }; + } catch (error) { + console.error("배치관리 연결 목록 조회 실패:", error); + return { + success: false, + message: "연결 목록 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * 배치관리용 테이블 목록 조회 + */ + static async getTablesFromConnection( + connectionType: 'internal' | 'external', + connectionId?: number + ): Promise> { + try { + let tables: BatchTableInfo[] = []; + + if (connectionType === 'internal') { + // 내부 DB 테이블 조회 + const result = await prisma.$queryRaw>` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_type = 'BASE TABLE' + ORDER BY table_name + `; + + tables = result.map(row => ({ + table_name: row.table_name, + columns: [] + })); + } else if (connectionType === 'external' && connectionId) { + // 외부 DB 테이블 조회 + const tablesResult = await this.getExternalTables(connectionId); + if (tablesResult.success && tablesResult.data) { + tables = tablesResult.data; + } + } + + return { + success: true, + data: tables, + message: `${tables.length}개의 테이블을 조회했습니다.` + }; + } catch (error) { + console.error("배치관리 테이블 목록 조회 실패:", error); + return { + success: false, + message: "테이블 목록 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * 배치관리용 테이블 컬럼 정보 조회 + */ + static async getTableColumns( + connectionType: 'internal' | 'external', + connectionId: number | undefined, + tableName: string + ): Promise> { + try { + console.log(`[BatchManagementService] getTableColumns 호출:`, { + connectionType, + connectionId, + tableName + }); + + let columns: BatchColumnInfo[] = []; + + if (connectionType === 'internal') { + // 내부 DB 컬럼 조회 + console.log(`[BatchManagementService] 내부 DB 컬럼 조회 시작: ${tableName}`); + + const result = await prisma.$queryRaw>` + SELECT + column_name, + data_type, + is_nullable, + column_default + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = ${tableName} + ORDER BY ordinal_position + `; + + console.log(`[BatchManagementService] 쿼리 결과:`, result); + + console.log(`[BatchManagementService] 내부 DB 컬럼 조회 결과:`, result); + + columns = result.map(row => ({ + column_name: row.column_name, + data_type: row.data_type, + is_nullable: row.is_nullable, + column_default: row.column_default, + })); + } else if (connectionType === 'external' && connectionId) { + // 외부 DB 컬럼 조회 + console.log(`[BatchManagementService] 외부 DB 컬럼 조회 시작: connectionId=${connectionId}, tableName=${tableName}`); + + const columnsResult = await this.getExternalTableColumns(connectionId, tableName); + + console.log(`[BatchManagementService] 외부 DB 컬럼 조회 결과:`, columnsResult); + + if (columnsResult.success && columnsResult.data) { + columns = columnsResult.data; + } + } + + console.log(`[BatchManagementService] 최종 컬럼 목록:`, columns); + return { + success: true, + data: columns, + message: `${columns.length}개의 컬럼을 조회했습니다.` + }; + } catch (error) { + console.error("[BatchManagementService] 컬럼 정보 조회 오류:", error); + return { + success: false, + message: "컬럼 정보 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * 외부 DB 테이블 목록 조회 (내부 구현) + */ + private static async getExternalTables(connectionId: number): Promise> { + try { + // 연결 정보 조회 + const connection = await prisma.external_db_connections.findUnique({ + where: { id: connectionId } + }); + + if (!connection) { + return { + success: false, + message: "연결 정보를 찾을 수 없습니다." + }; + } + + // 비밀번호 복호화 + const decryptedPassword = PasswordEncryption.decrypt(connection.password); + if (!decryptedPassword) { + return { + success: false, + message: "비밀번호 복호화에 실패했습니다." + }; + } + + // 연결 설정 준비 + const config = { + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username, + password: decryptedPassword, + connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined, + queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined, + ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false + }; + + // DatabaseConnectorFactory를 통한 테이블 목록 조회 + const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, connectionId); + const tables = await connector.getTables(); + + return { + success: true, + message: "테이블 목록을 조회했습니다.", + data: tables + }; + } catch (error) { + console.error("외부 DB 테이블 목록 조회 오류:", error); + return { + success: false, + message: "테이블 목록 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * 외부 DB 테이블 컬럼 정보 조회 (내부 구현) + */ + private static async getExternalTableColumns(connectionId: number, tableName: string): Promise> { + try { + console.log(`[BatchManagementService] getExternalTableColumns 호출: connectionId=${connectionId}, tableName=${tableName}`); + + // 연결 정보 조회 + const connection = await prisma.external_db_connections.findUnique({ + where: { id: connectionId } + }); + + if (!connection) { + console.log(`[BatchManagementService] 연결 정보를 찾을 수 없음: connectionId=${connectionId}`); + return { + success: false, + message: "연결 정보를 찾을 수 없습니다." + }; + } + + console.log(`[BatchManagementService] 연결 정보 조회 성공:`, { + id: connection.id, + connection_name: connection.connection_name, + db_type: connection.db_type, + host: connection.host, + port: connection.port, + database_name: connection.database_name + }); + + // 비밀번호 복호화 + const decryptedPassword = PasswordEncryption.decrypt(connection.password); + + // 연결 설정 준비 + const config = { + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username, + password: decryptedPassword, + connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined, + queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined, + ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false + }; + + console.log(`[BatchManagementService] 커넥터 생성 시작: db_type=${connection.db_type}`); + + // 데이터베이스 타입에 따른 커넥터 생성 + const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, connectionId); + + console.log(`[BatchManagementService] 커넥터 생성 완료, 컬럼 조회 시작: tableName=${tableName}`); + + // 컬럼 정보 조회 + console.log(`[BatchManagementService] connector.getColumns 호출 전`); + const columns = await connector.getColumns(tableName); + + console.log(`[BatchManagementService] 원본 컬럼 조회 결과:`, columns); + console.log(`[BatchManagementService] 원본 컬럼 개수:`, columns ? columns.length : 'null/undefined'); + + // 각 데이터베이스 커넥터의 반환 구조가 다르므로 통일된 구조로 변환 + const standardizedColumns: BatchColumnInfo[] = columns.map((col: any) => { + console.log(`[BatchManagementService] 컬럼 변환 중:`, col); + + // MySQL/MariaDB 구조: {name, dataType, isNullable, defaultValue} (MySQLConnector만) + if (col.name && col.dataType !== undefined) { + const result = { + column_name: col.name, + data_type: col.dataType, + is_nullable: col.isNullable ? 'YES' : 'NO', + column_default: col.defaultValue || null, + }; + console.log(`[BatchManagementService] MySQL/MariaDB 구조로 변환:`, result); + return result; + } + // PostgreSQL/Oracle/MSSQL/MariaDB 구조: {column_name, data_type, is_nullable, column_default} + else { + const result = { + column_name: col.column_name || col.COLUMN_NAME, + data_type: col.data_type || col.DATA_TYPE, + is_nullable: col.is_nullable || col.IS_NULLABLE || (col.nullable === 'Y' ? 'YES' : 'NO'), + column_default: col.column_default || col.COLUMN_DEFAULT || null, + }; + console.log(`[BatchManagementService] 표준 구조로 변환:`, result); + return result; + } + }); + + console.log(`[BatchManagementService] 표준화된 컬럼 목록:`, standardizedColumns); + + return { + success: true, + data: standardizedColumns, + message: "컬럼 정보를 조회했습니다." + }; + } catch (error) { + console.error("[BatchManagementService] 외부 DB 컬럼 정보 조회 오류:", error); + console.error("[BatchManagementService] 오류 스택:", error instanceof Error ? error.stack : 'No stack trace'); + return { + success: false, + message: "컬럼 정보 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } +} + diff --git a/backend-node/src/services/batchSchedulerService.ts b/backend-node/src/services/batchSchedulerService.ts new file mode 100644 index 00000000..3d032291 --- /dev/null +++ b/backend-node/src/services/batchSchedulerService.ts @@ -0,0 +1,484 @@ +// 배치 스케줄러 서비스 +// 작성일: 2024-12-24 + +import * as cron from 'node-cron'; +import prisma from '../config/database'; +import { BatchService } from './batchService'; +import { BatchExecutionLogService } from './batchExecutionLogService'; +import { logger } from '../utils/logger'; + +export class BatchSchedulerService { + private static scheduledTasks: Map = new Map(); + private static isInitialized = false; + + /** + * 스케줄러 초기화 + */ + static async initialize() { + if (this.isInitialized) { + logger.info('배치 스케줄러가 이미 초기화되었습니다.'); + return; + } + + try { + logger.info('배치 스케줄러 초기화 시작...'); + + // 활성화된 배치 설정들을 로드하여 스케줄 등록 + await this.loadActiveBatchConfigs(); + + this.isInitialized = true; + logger.info('배치 스케줄러 초기화 완료'); + } catch (error) { + logger.error('배치 스케줄러 초기화 실패:', error); + throw error; + } + } + + /** + * 활성화된 배치 설정들을 로드하여 스케줄 등록 + */ + private static async loadActiveBatchConfigs() { + try { + const activeConfigs = await prisma.batch_configs.findMany({ + where: { + is_active: 'Y' + }, + include: { + batch_mappings: true + } + }); + + logger.info(`활성화된 배치 설정 ${activeConfigs.length}개 발견`); + + for (const config of activeConfigs) { + await this.scheduleBatchConfig(config); + } + } catch (error) { + logger.error('활성화된 배치 설정 로드 실패:', error); + throw error; + } + } + + /** + * 배치 설정을 스케줄에 등록 + */ + static async scheduleBatchConfig(config: any) { + try { + const { id, batch_name, cron_schedule } = config; + + // 기존 스케줄이 있다면 제거 + if (this.scheduledTasks.has(id)) { + this.scheduledTasks.get(id)?.stop(); + this.scheduledTasks.delete(id); + } + + // cron 스케줄 유효성 검사 + if (!cron.validate(cron_schedule)) { + logger.error(`잘못된 cron 스케줄: ${cron_schedule} (배치 ID: ${id})`); + return; + } + + // 새로운 스케줄 등록 + const task = cron.schedule(cron_schedule, async () => { + logger.info(`🔄 스케줄 배치 실행 시작: ${batch_name} (ID: ${id})`); + await this.executeBatchConfig(config); + }); + + // 스케줄 시작 (기본적으로 시작되지만 명시적으로 호출) + task.start(); + + this.scheduledTasks.set(id, task); + logger.info(`배치 스케줄 등록 완료: ${batch_name} (ID: ${id}, Schedule: ${cron_schedule}) - 스케줄 시작됨`); + } catch (error) { + logger.error(`배치 스케줄 등록 실패 (ID: ${config.id}):`, error); + } + } + + /** + * 배치 설정 스케줄 제거 + */ + static async unscheduleBatchConfig(batchConfigId: number) { + try { + if (this.scheduledTasks.has(batchConfigId)) { + this.scheduledTasks.get(batchConfigId)?.stop(); + this.scheduledTasks.delete(batchConfigId); + logger.info(`배치 스케줄 제거 완료 (ID: ${batchConfigId})`); + } + } catch (error) { + logger.error(`배치 스케줄 제거 실패 (ID: ${batchConfigId}):`, error); + } + } + + /** + * 배치 설정 업데이트 시 스케줄 재등록 + */ + static async updateBatchSchedule(configId: number) { + try { + // 기존 스케줄 제거 + await this.unscheduleBatchConfig(configId); + + // 업데이트된 배치 설정 조회 + const config = await prisma.batch_configs.findUnique({ + where: { id: configId }, + include: { batch_mappings: true } + }); + + if (!config) { + logger.warn(`배치 설정을 찾을 수 없습니다: ID ${configId}`); + return; + } + + // 활성화된 배치만 다시 스케줄 등록 + if (config.is_active === 'Y') { + await this.scheduleBatchConfig(config); + logger.info(`배치 스케줄 업데이트 완료: ${config.batch_name} (ID: ${configId})`); + } else { + logger.info(`비활성화된 배치 스케줄 제거: ${config.batch_name} (ID: ${configId})`); + } + } catch (error) { + logger.error(`배치 스케줄 업데이트 실패: ID ${configId}`, error); + } + } + + /** + * 배치 설정 실행 + */ + private static async executeBatchConfig(config: any) { + const startTime = new Date(); + let executionLog: any = null; + + try { + logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id})`); + + // 실행 로그 생성 + const executionLogResponse = await BatchExecutionLogService.createExecutionLog({ + batch_config_id: config.id, + execution_status: 'RUNNING', + start_time: startTime, + total_records: 0, + success_records: 0, + failed_records: 0 + }); + + if (!executionLogResponse.success || !executionLogResponse.data) { + logger.error(`배치 실행 로그 생성 실패: ${config.batch_name}`, executionLogResponse.message); + return; + } + + executionLog = executionLogResponse.data; + + // 실제 배치 실행 로직 (수동 실행과 동일한 로직 사용) + const result = await this.executeBatchMappings(config); + + // 실행 로그 업데이트 (성공) + await BatchExecutionLogService.updateExecutionLog(executionLog.id, { + execution_status: 'SUCCESS', + end_time: new Date(), + duration_ms: Date.now() - startTime.getTime(), + total_records: result.totalRecords, + success_records: result.successRecords, + failed_records: result.failedRecords + }); + + logger.info(`배치 실행 완료: ${config.batch_name} (처리된 레코드: ${result.totalRecords})`); + } catch (error) { + logger.error(`배치 실행 실패: ${config.batch_name}`, error); + + // 실행 로그 업데이트 (실패) + if (executionLog) { + await BatchExecutionLogService.updateExecutionLog(executionLog.id, { + execution_status: 'FAILED', + end_time: new Date(), + duration_ms: Date.now() - startTime.getTime(), + error_message: error instanceof Error ? error.message : '알 수 없는 오류', + error_details: error instanceof Error ? error.stack : String(error) + }); + } + } + } + + /** + * 배치 매핑 실행 (수동 실행과 동일한 로직) + */ + private static async executeBatchMappings(config: any) { + let totalRecords = 0; + let successRecords = 0; + let failedRecords = 0; + + if (!config.batch_mappings || config.batch_mappings.length === 0) { + logger.warn(`배치 매핑이 없습니다: ${config.batch_name}`); + return { totalRecords, successRecords, failedRecords }; + } + + // 테이블별로 매핑을 그룹화 + const tableGroups = new Map(); + + for (const mapping of config.batch_mappings) { + const key = `${mapping.from_connection_type}:${mapping.from_connection_id || 'internal'}:${mapping.from_table_name}`; + if (!tableGroups.has(key)) { + tableGroups.set(key, []); + } + tableGroups.get(key)!.push(mapping); + } + + // 각 테이블 그룹별로 처리 + for (const [tableKey, mappings] of tableGroups) { + try { + const firstMapping = mappings[0]; + logger.info(`테이블 처리 시작: ${tableKey} -> ${mappings.length}개 컬럼 매핑`); + + let fromData: any[] = []; + + // FROM 데이터 조회 (DB 또는 REST API) + if (firstMapping.from_connection_type === 'restapi') { + // REST API에서 데이터 조회 + logger.info(`REST API에서 데이터 조회: ${firstMapping.from_api_url}${firstMapping.from_table_name}`); + const { BatchExternalDbService } = await import('./batchExternalDbService'); + const apiResult = await BatchExternalDbService.getDataFromRestApi( + firstMapping.from_api_url!, + firstMapping.from_api_key!, + firstMapping.from_table_name, + firstMapping.from_api_method as 'GET' | 'POST' | 'PUT' | 'DELETE' || 'GET', + mappings.map((m: any) => m.from_column_name) + ); + + if (apiResult.success && apiResult.data) { + fromData = apiResult.data; + } else { + throw new Error(`REST API 데이터 조회 실패: ${apiResult.message}`); + } + } else { + // DB에서 데이터 조회 + const fromColumns = mappings.map((m: any) => m.from_column_name); + fromData = await BatchService.getDataFromTableWithColumns( + firstMapping.from_table_name, + fromColumns, + firstMapping.from_connection_type as 'internal' | 'external', + firstMapping.from_connection_id || undefined + ); + } + + totalRecords += fromData.length; + + // 컬럼 매핑 적용하여 TO 테이블 형식으로 변환 + const mappedData = fromData.map(row => { + const mappedRow: any = {}; + for (const mapping of mappings) { + // DB → REST API 배치인지 확인 + if (firstMapping.to_connection_type === 'restapi' && mapping.to_api_body) { + // DB → REST API: 원본 컬럼명을 키로 사용 (템플릿 처리용) + mappedRow[mapping.from_column_name] = row[mapping.from_column_name]; + } else { + // 기존 로직: to_column_name을 키로 사용 + mappedRow[mapping.to_column_name] = row[mapping.from_column_name]; + } + } + return mappedRow; + }); + + // TO 테이블에 데이터 삽입 (DB 또는 REST API) + let insertResult: { successCount: number; failedCount: number }; + + if (firstMapping.to_connection_type === 'restapi') { + // REST API로 데이터 전송 + logger.info(`REST API로 데이터 전송: ${firstMapping.to_api_url}${firstMapping.to_table_name}`); + const { BatchExternalDbService } = await import('./batchExternalDbService'); + + // DB → REST API 배치인지 확인 (to_api_body가 있으면 템플릿 기반) + const hasTemplate = mappings.some((m: any) => m.to_api_body); + + if (hasTemplate) { + // 템플릿 기반 REST API 전송 (DB → REST API 배치) + const templateBody = firstMapping.to_api_body || '{}'; + logger.info(`템플릿 기반 REST API 전송, Request Body 템플릿: ${templateBody}`); + + // URL 경로 컬럼 찾기 (PUT/DELETE용) + const urlPathColumn = mappings.find((m: any) => m.to_column_name === 'URL_PATH_PARAM')?.from_column_name; + + const apiResult = await BatchExternalDbService.sendDataToRestApiWithTemplate( + firstMapping.to_api_url!, + firstMapping.to_api_key!, + firstMapping.to_table_name, + firstMapping.to_api_method as 'POST' | 'PUT' | 'DELETE' || 'POST', + templateBody, + mappedData, + urlPathColumn + ); + + if (apiResult.success && apiResult.data) { + insertResult = apiResult.data; + } else { + throw new Error(`템플릿 기반 REST API 데이터 전송 실패: ${apiResult.message}`); + } + } else { + // 기존 REST API 전송 (REST API → DB 배치) + const apiResult = await BatchExternalDbService.sendDataToRestApi( + firstMapping.to_api_url!, + firstMapping.to_api_key!, + firstMapping.to_table_name, + firstMapping.to_api_method as 'POST' | 'PUT' || 'POST', + mappedData + ); + + if (apiResult.success && apiResult.data) { + insertResult = apiResult.data; + } else { + throw new Error(`REST API 데이터 전송 실패: ${apiResult.message}`); + } + } + } else { + // DB에 데이터 삽입 + insertResult = await BatchService.insertDataToTable( + firstMapping.to_table_name, + mappedData, + firstMapping.to_connection_type as 'internal' | 'external', + firstMapping.to_connection_id || undefined + ); + } + + successRecords += insertResult.successCount; + failedRecords += insertResult.failedCount; + + logger.info(`테이블 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`); + } catch (error) { + logger.error(`테이블 처리 실패: ${tableKey}`, error); + failedRecords += 1; + } + } + + return { totalRecords, successRecords, failedRecords }; + } + + /** + * 배치 매핑 처리 (기존 메서드 - 사용 안 함) + */ + private static async processBatchMappings(config: any) { + const { batch_mappings } = config; + let totalRecords = 0; + let successRecords = 0; + let failedRecords = 0; + + if (!batch_mappings || batch_mappings.length === 0) { + logger.warn(`배치 매핑이 없습니다: ${config.batch_name}`); + return { totalRecords, successRecords, failedRecords }; + } + + for (const mapping of batch_mappings) { + try { + logger.info(`매핑 처리 시작: ${mapping.from_table_name} -> ${mapping.to_table_name}`); + + // FROM 테이블에서 데이터 조회 + const fromData = await this.getDataFromSource(mapping); + totalRecords += fromData.length; + + // TO 테이블에 데이터 삽입 + const insertResult = await this.insertDataToTarget(mapping, fromData); + successRecords += insertResult.successCount; + failedRecords += insertResult.failedCount; + + logger.info(`매핑 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`); + } catch (error) { + logger.error(`매핑 처리 실패: ${mapping.from_table_name} -> ${mapping.to_table_name}`, error); + failedRecords += 1; + } + } + + return { totalRecords, successRecords, failedRecords }; + } + + /** + * FROM 테이블에서 데이터 조회 + */ + private static async getDataFromSource(mapping: any) { + try { + if (mapping.from_connection_type === 'internal') { + // 내부 DB에서 조회 + const result = await prisma.$queryRawUnsafe( + `SELECT * FROM ${mapping.from_table_name}` + ); + return result as any[]; + } else { + // 외부 DB에서 조회 (구현 필요) + logger.warn('외부 DB 조회는 아직 구현되지 않았습니다.'); + return []; + } + } catch (error) { + logger.error(`FROM 테이블 데이터 조회 실패: ${mapping.from_table_name}`, error); + throw error; + } + } + + /** + * TO 테이블에 데이터 삽입 + */ + private static async insertDataToTarget(mapping: any, data: any[]) { + let successCount = 0; + let failedCount = 0; + + try { + if (mapping.to_connection_type === 'internal') { + // 내부 DB에 삽입 + for (const record of data) { + try { + // 매핑된 컬럼만 추출 + const mappedData = this.mapColumns(record, mapping); + + await prisma.$executeRawUnsafe( + `INSERT INTO ${mapping.to_table_name} (${Object.keys(mappedData).join(', ')}) VALUES (${Object.values(mappedData).map(() => '?').join(', ')})`, + ...Object.values(mappedData) + ); + successCount++; + } catch (error) { + logger.error(`레코드 삽입 실패:`, error); + failedCount++; + } + } + } else { + // 외부 DB에 삽입 (구현 필요) + logger.warn('외부 DB 삽입은 아직 구현되지 않았습니다.'); + failedCount = data.length; + } + } catch (error) { + logger.error(`TO 테이블 데이터 삽입 실패: ${mapping.to_table_name}`, error); + throw error; + } + + return { successCount, failedCount }; + } + + /** + * 컬럼 매핑 + */ + private static mapColumns(record: any, mapping: any) { + const mappedData: any = {}; + + // 단순한 컬럼 매핑 (실제로는 더 복잡한 로직 필요) + mappedData[mapping.to_column_name] = record[mapping.from_column_name]; + + return mappedData; + } + + /** + * 모든 스케줄 중지 + */ + static async stopAllSchedules() { + try { + for (const [id, task] of this.scheduledTasks) { + task.stop(); + logger.info(`배치 스케줄 중지: ID ${id}`); + } + this.scheduledTasks.clear(); + this.isInitialized = false; + logger.info('모든 배치 스케줄이 중지되었습니다.'); + } catch (error) { + logger.error('배치 스케줄 중지 실패:', error); + } + } + + /** + * 현재 등록된 스케줄 목록 조회 + */ + static getScheduledTasks() { + return Array.from(this.scheduledTasks.keys()); + } +} diff --git a/backend-node/src/services/batchService.ts b/backend-node/src/services/batchService.ts index f20945d8..edac1629 100644 --- a/backend-node/src/services/batchService.ts +++ b/backend-node/src/services/batchService.ts @@ -1,275 +1,807 @@ -// 배치 관리 서비스 -// 작성일: 2024-12-23 +// 배치관리 서비스 +// 작성일: 2024-12-24 -import { PrismaClient } from "@prisma/client"; +import prisma from "../config/database"; import { - BatchJob, - BatchJobFilter, - BatchExecution, - BatchMonitoring, -} from "../types/batchManagement"; - -const prisma = new PrismaClient(); + BatchConfig, + BatchMapping, + BatchConfigFilter, + BatchMappingRequest, + BatchValidationResult, + ApiResponse, + ConnectionInfo, + TableInfo, + ColumnInfo, + CreateBatchConfigRequest, + UpdateBatchConfigRequest, +} from "../types/batchTypes"; +import { BatchExternalDbService } from "./batchExternalDbService"; +import { DbConnectionManager } from "./dbConnectionManager"; export class BatchService { /** - * 배치 작업 목록 조회 + * 배치 설정 목록 조회 */ - static async getBatchJobs(filter: BatchJobFilter): Promise { - const whereCondition: any = { - company_code: filter.company_code || "*", - }; + static async getBatchConfigs( + filter: BatchConfigFilter + ): Promise> { + try { + const where: any = {}; - if (filter.job_name) { - whereCondition.job_name = { - contains: filter.job_name, - mode: "insensitive", + // 필터 조건 적용 + if (filter.is_active) { + where.is_active = filter.is_active; + } + + if (filter.company_code) { + where.company_code = filter.company_code; + } + + // 검색 조건 적용 + if (filter.search && filter.search.trim()) { + where.OR = [ + { + batch_name: { + contains: filter.search.trim(), + mode: "insensitive", + }, + }, + { + description: { + contains: filter.search.trim(), + mode: "insensitive", + }, + }, + ]; + } + + const page = filter.page || 1; + const limit = filter.limit || 10; + const skip = (page - 1) * limit; + + const [batchConfigs, total] = await Promise.all([ + prisma.batch_configs.findMany({ + where, + include: { + batch_mappings: true, + }, + orderBy: [{ is_active: "desc" }, { batch_name: "asc" }], + skip, + take: limit, + }), + prisma.batch_configs.count({ where }), + ]); + + return { + success: true, + data: batchConfigs as BatchConfig[], + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }; + } catch (error) { + console.error("배치 설정 목록 조회 오류:", error); + return { + success: false, + message: "배치 설정 목록 조회에 실패했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", }; } - - if (filter.job_type) { - whereCondition.job_type = filter.job_type; - } - - if (filter.is_active) { - whereCondition.is_active = filter.is_active === "Y"; - } - - if (filter.search) { - whereCondition.OR = [ - { job_name: { contains: filter.search, mode: "insensitive" } }, - { description: { contains: filter.search, mode: "insensitive" } }, - ]; - } - - const jobs = await prisma.batch_jobs.findMany({ - where: whereCondition, - orderBy: { created_date: "desc" }, - }); - - return jobs.map((job: any) => ({ - ...job, - is_active: job.is_active ? "Y" : "N", - })) as BatchJob[]; } /** - * 배치 작업 상세 조회 + * 특정 배치 설정 조회 */ - static async getBatchJobById(id: number): Promise { - const job = await prisma.batch_jobs.findUnique({ - where: { id }, - }); + static async getBatchConfigById( + id: number + ): Promise> { + try { + const batchConfig = await prisma.batch_configs.findUnique({ + where: { id }, + include: { + batch_mappings: { + orderBy: [ + { from_table_name: "asc" }, + { from_column_name: "asc" }, + { mapping_order: "asc" }, + ], + }, + }, + }); - if (!job) return null; + if (!batchConfig) { + return { + success: false, + message: "배치 설정을 찾을 수 없습니다.", + }; + } - return { - ...job, - is_active: job.is_active ? "Y" : "N", - } as BatchJob; - } - - /** - * 배치 작업 생성 - */ - static async createBatchJob(data: BatchJob): Promise { - const { id, config_json, ...createData } = data; - const job = await prisma.batch_jobs.create({ - data: { - ...createData, - is_active: data.is_active, - config_json: config_json || undefined, - created_date: new Date(), - updated_date: new Date(), - }, - }); - - return { - ...job, - is_active: job.is_active ? "Y" : "N", - } as BatchJob; - } - - /** - * 배치 작업 수정 - */ - static async updateBatchJob( - id: number, - data: Partial - ): Promise { - const updateData: any = { - ...data, - updated_date: new Date(), - }; - - if (data.is_active !== undefined) { - updateData.is_active = data.is_active; + return { + success: true, + data: batchConfig as BatchConfig, + }; + } catch (error) { + console.error("배치 설정 조회 오류:", error); + return { + success: false, + message: "배치 설정 조회에 실패했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; } - - const job = await prisma.batch_jobs.update({ - where: { id }, - data: updateData, - }); - - return { - ...job, - is_active: job.is_active ? "Y" : "N", - } as BatchJob; } /** - * 배치 작업 삭제 + * 배치 설정 생성 */ - static async deleteBatchJob(id: number): Promise { - await prisma.batch_jobs.delete({ - where: { id }, - }); - } - - /** - * 배치 작업 수동 실행 - */ - static async executeBatchJob(id: number): Promise { - const job = await prisma.batch_jobs.findUnique({ - where: { id }, - }); - - if (!job) { - throw new Error("배치 작업을 찾을 수 없습니다."); - } - - if (!job.is_active) { - throw new Error("비활성화된 배치 작업입니다."); - } - - // 배치 실행 기록 생성 - const execution = await prisma.batch_job_executions.create({ - data: { - job_id: id, - execution_id: `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, - status: "RUNNING", - start_time: new Date(), - created_at: new Date(), - }, - }); - - // 실제 배치 작업 실행 로직은 여기에 구현 - // 현재는 시뮬레이션으로 처리 - setTimeout(async () => { - try { - // 배치 작업 시뮬레이션 - await new Promise((resolve) => setTimeout(resolve, 5000)); - - await prisma.batch_job_executions.update({ - where: { id: execution.id }, + static async createBatchConfig( + data: CreateBatchConfigRequest, + userId?: string + ): Promise> { + try { + // 트랜잭션으로 배치 설정과 매핑 생성 + const result = await prisma.$transaction(async (tx) => { + // 배치 설정 생성 + const batchConfig = await tx.batch_configs.create({ data: { - status: "SUCCESS", - end_time: new Date(), - exit_message: "배치 작업이 성공적으로 완료되었습니다.", + batch_name: data.batchName, + description: data.description, + cron_schedule: data.cronSchedule, + created_by: userId, + updated_by: userId, }, }); - } catch (error) { - await prisma.batch_job_executions.update({ - where: { id: execution.id }, - data: { - status: "FAILED", - end_time: new Date(), - exit_message: - error instanceof Error ? error.message : "알 수 없는 오류", - }, + + // 배치 매핑 생성 + const mappings = await Promise.all( + data.mappings.map((mapping, index) => + tx.batch_mappings.create({ + data: { + batch_config_id: batchConfig.id, + from_connection_type: mapping.from_connection_type, + from_connection_id: mapping.from_connection_id, + from_table_name: mapping.from_table_name, + from_column_name: mapping.from_column_name, + from_column_type: mapping.from_column_type, + from_api_url: mapping.from_api_url, + from_api_key: mapping.from_api_key, + from_api_method: mapping.from_api_method, + to_connection_type: mapping.to_connection_type, + to_connection_id: mapping.to_connection_id, + to_table_name: mapping.to_table_name, + to_column_name: mapping.to_column_name, + to_column_type: mapping.to_column_type, + to_api_url: mapping.to_api_url, + to_api_key: mapping.to_api_key, + to_api_method: mapping.to_api_method, + // to_api_body: mapping.to_api_body, // Request Body 템플릿 추가 - 임시 주석 처리 + mapping_order: mapping.mapping_order || index + 1, + created_by: userId, + }, + }) + ) + ); + + return { + ...batchConfig, + batch_mappings: mappings, + }; + }); + + return { + success: true, + data: result as BatchConfig, + message: "배치 설정이 성공적으로 생성되었습니다.", + }; + } catch (error) { + console.error("배치 설정 생성 오류:", error); + return { + success: false, + message: "배치 설정 생성에 실패했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; + } + } + + /** + * 배치 설정 수정 + */ + static async updateBatchConfig( + id: number, + data: UpdateBatchConfigRequest, + userId?: string + ): Promise> { + try { + // 기존 배치 설정 확인 + const existingConfig = await prisma.batch_configs.findUnique({ + where: { id }, + include: { batch_mappings: true }, + }); + + if (!existingConfig) { + return { + success: false, + message: "배치 설정을 찾을 수 없습니다.", + }; + } + + // 트랜잭션으로 업데이트 + const result = await prisma.$transaction(async (tx) => { + // 배치 설정 업데이트 + const updateData: any = { + updated_by: userId, + }; + + if (data.batchName) updateData.batch_name = data.batchName; + if (data.description !== undefined) updateData.description = data.description; + if (data.cronSchedule) updateData.cron_schedule = data.cronSchedule; + if (data.isActive !== undefined) updateData.is_active = data.isActive; + + const batchConfig = await tx.batch_configs.update({ + where: { id }, + data: updateData, + }); + + // 매핑이 제공된 경우 기존 매핑 삭제 후 새로 생성 + if (data.mappings) { + await tx.batch_mappings.deleteMany({ + where: { batch_config_id: id }, + }); + + const mappings = await Promise.all( + data.mappings.map((mapping, index) => + tx.batch_mappings.create({ + data: { + batch_config_id: id, + from_connection_type: mapping.from_connection_type, + from_connection_id: mapping.from_connection_id, + from_table_name: mapping.from_table_name, + from_column_name: mapping.from_column_name, + from_column_type: mapping.from_column_type, + to_connection_type: mapping.to_connection_type, + to_connection_id: mapping.to_connection_id, + to_table_name: mapping.to_table_name, + to_column_name: mapping.to_column_name, + to_column_type: mapping.to_column_type, + mapping_order: mapping.mapping_order || index + 1, + created_by: userId, + }, + }) + ) + ); + + return { + ...batchConfig, + batch_mappings: mappings, + }; + } else { + return { + ...batchConfig, + batch_mappings: existingConfig.batch_mappings, + }; + } + }); + + return { + success: true, + data: result as BatchConfig, + message: "배치 설정이 성공적으로 수정되었습니다.", + }; + } catch (error) { + console.error("배치 설정 수정 오류:", error); + return { + success: false, + message: "배치 설정 수정에 실패했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; + } + } + + /** + * 배치 설정 삭제 (논리 삭제) + */ + static async deleteBatchConfig( + id: number, + userId?: string + ): Promise> { + try { + const existingConfig = await prisma.batch_configs.findUnique({ + where: { id }, + }); + + if (!existingConfig) { + return { + success: false, + message: "배치 설정을 찾을 수 없습니다.", + }; + } + + // 배치 매핑 먼저 삭제 (외래키 제약) + await prisma.batch_mappings.deleteMany({ + where: { batch_config_id: id } + }); + + // 배치 설정 삭제 + await prisma.batch_configs.delete({ + where: { id } + }); + + return { + success: true, + message: "배치 설정이 성공적으로 삭제되었습니다.", + }; + } catch (error) { + console.error("배치 설정 삭제 오류:", error); + return { + success: false, + message: "배치 설정 삭제에 실패했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; + } + } + + /** + * 사용 가능한 커넥션 목록 조회 + */ + static async getAvailableConnections(): Promise> { + try { + const connections: ConnectionInfo[] = []; + + // 내부 DB 추가 + connections.push({ + type: 'internal', + name: 'Internal Database', + db_type: 'postgresql', + }); + + // 외부 DB 연결 조회 + const externalConnections = await BatchExternalDbService.getAvailableConnections(); + + if (externalConnections.success && externalConnections.data) { + externalConnections.data.forEach((conn) => { + connections.push({ + type: 'external', + id: conn.id, + name: conn.name, + db_type: conn.db_type, + }); }); } - }, 0); - return { - ...execution, - execution_status: execution.status as any, - started_at: execution.start_time, - completed_at: execution.end_time, - error_message: execution.exit_message, - } as BatchExecution; + return { + success: true, + data: connections, + }; + } catch (error) { + console.error("커넥션 목록 조회 오류:", error); + return { + success: false, + message: "커넥션 목록 조회에 실패했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; + } } /** - * 배치 실행 목록 조회 + * 특정 커넥션의 테이블 목록 조회 */ - static async getBatchExecutions(jobId?: number): Promise { - const whereCondition: any = {}; + static async getTablesFromConnection( + connectionType: 'internal' | 'external', + connectionId?: number + ): Promise> { + try { + let tables: TableInfo[] = []; - if (jobId) { - whereCondition.job_id = jobId; + if (connectionType === 'internal') { + // 내부 DB 테이블 조회 + const result = await prisma.$queryRaw>` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_type = 'BASE TABLE' + ORDER BY table_name + `; + tables = result.map(row => ({ + table_name: row.table_name, + columns: [] + })); + } else if (connectionType === 'external' && connectionId) { + // 외부 DB 테이블 조회 + const tablesResult = await BatchExternalDbService.getTablesFromConnection(connectionType, connectionId); + if (tablesResult.success && tablesResult.data) { + tables = tablesResult.data; + } + } + + return { + success: true, + data: tables, + }; + } catch (error) { + console.error("테이블 목록 조회 오류:", error); + return { + success: false, + message: "테이블 목록 조회에 실패했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; + } + } + + /** + * 특정 테이블의 컬럼 정보 조회 + */ + static async getTableColumns( + connectionType: 'internal' | 'external', + connectionId: number | undefined, + tableName: string + ): Promise> { + try { + console.log(`[BatchService] getTableColumns 호출:`, { + connectionType, + connectionId, + tableName + }); + + let columns: ColumnInfo[] = []; + + if (connectionType === 'internal') { + // 내부 DB 컬럼 조회 + console.log(`[BatchService] 내부 DB 컬럼 조회 시작: ${tableName}`); + + const result = await prisma.$queryRaw>` + SELECT + column_name, + data_type, + is_nullable, + column_default + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = ${tableName} + ORDER BY ordinal_position + `; + + console.log(`[BatchService] 내부 DB 컬럼 조회 결과:`, result); + + columns = result.map(row => ({ + column_name: row.column_name, + data_type: row.data_type, + is_nullable: row.is_nullable, + column_default: row.column_default, + })); + } else if (connectionType === 'external' && connectionId) { + // 외부 DB 컬럼 조회 + console.log(`[BatchService] 외부 DB 컬럼 조회 시작: connectionId=${connectionId}, tableName=${tableName}`); + + const columnsResult = await BatchExternalDbService.getTableColumns( + connectionType, + connectionId, + tableName + ); + + console.log(`[BatchService] 외부 DB 컬럼 조회 결과:`, columnsResult); + + if (columnsResult.success && columnsResult.data) { + columns = columnsResult.data; + } + + console.log(`[BatchService] 외부 DB 컬럼:`, columns); + } + + return { + success: true, + data: columns, + }; + } catch (error) { + console.error("컬럼 정보 조회 오류:", error); + return { + success: false, + message: "컬럼 정보 조회에 실패했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; + } + } + + /** + * 배치 실행 로그 생성 + */ + static async createExecutionLog(data: { + batch_config_id: number; + execution_status: string; + start_time: Date; + total_records: number; + success_records: number; + failed_records: number; + }): Promise { + try { + const executionLog = await prisma.batch_execution_logs.create({ + data: { + batch_config_id: data.batch_config_id, + execution_status: data.execution_status, + start_time: data.start_time, + total_records: data.total_records, + success_records: data.success_records, + failed_records: data.failed_records, + }, + }); + + return executionLog; + } catch (error) { + console.error("배치 실행 로그 생성 오류:", error); + throw error; + } + } + + /** + * 배치 실행 로그 업데이트 + */ + static async updateExecutionLog( + id: number, + data: { + execution_status?: string; + end_time?: Date; + duration_ms?: number; + total_records?: number; + success_records?: number; + failed_records?: number; + error_message?: string; + } + ): Promise { + try { + await prisma.batch_execution_logs.update({ + where: { id }, + data, + }); + } catch (error) { + console.error("배치 실행 로그 업데이트 오류:", error); + throw error; + } + } + + /** + * 테이블에서 데이터 조회 (연결 타입에 따라 내부/외부 DB 구분) + */ + static async getDataFromTable( + tableName: string, + connectionType: 'internal' | 'external' = 'internal', + connectionId?: number + ): Promise { + try { + console.log(`[BatchService] 테이블에서 데이터 조회: ${tableName} (${connectionType}${connectionId ? `:${connectionId}` : ''})`); + + if (connectionType === 'internal') { + // 내부 DB에서 데이터 조회 + const result = await prisma.$queryRawUnsafe(`SELECT * FROM ${tableName} LIMIT 100`); + console.log(`[BatchService] 내부 DB 데이터 조회 결과: ${Array.isArray(result) ? result.length : 0}개 레코드`); + return result as any[]; + } else if (connectionType === 'external' && connectionId) { + // 외부 DB에서 데이터 조회 + const result = await BatchExternalDbService.getDataFromTable(connectionId, tableName); + if (result.success && result.data) { + console.log(`[BatchService] 외부 DB 데이터 조회 결과: ${result.data.length}개 레코드`); + return result.data; + } else { + console.error(`외부 DB 데이터 조회 실패: ${result.message}`); + return []; + } + } else { + throw new Error(`잘못된 연결 타입 또는 연결 ID: ${connectionType}, ${connectionId}`); + } + } catch (error) { + console.error(`테이블 데이터 조회 오류 (${tableName}):`, error); + throw error; + } + } + + /** + * 테이블에서 특정 컬럼들만 조회 (연결 타입에 따라 내부/외부 DB 구분) + */ + static async getDataFromTableWithColumns( + tableName: string, + columns: string[], + connectionType: 'internal' | 'external' = 'internal', + connectionId?: number + ): Promise { + try { + console.log(`[BatchService] 테이블에서 특정 컬럼 데이터 조회: ${tableName} (${columns.join(', ')}) (${connectionType}${connectionId ? `:${connectionId}` : ''})`); + + if (connectionType === 'internal') { + // 내부 DB에서 특정 컬럼만 조회 + const columnList = columns.join(', '); + const result = await prisma.$queryRawUnsafe(`SELECT ${columnList} FROM ${tableName} LIMIT 100`); + console.log(`[BatchService] 내부 DB 특정 컬럼 조회 결과: ${Array.isArray(result) ? result.length : 0}개 레코드`); + return result as any[]; + } else if (connectionType === 'external' && connectionId) { + // 외부 DB에서 특정 컬럼만 조회 + const result = await BatchExternalDbService.getDataFromTableWithColumns(connectionId, tableName, columns); + if (result.success && result.data) { + console.log(`[BatchService] 외부 DB 특정 컬럼 조회 결과: ${result.data.length}개 레코드`); + return result.data; + } else { + console.error(`외부 DB 특정 컬럼 조회 실패: ${result.message}`); + return []; + } + } else { + throw new Error(`잘못된 연결 타입 또는 연결 ID: ${connectionType}, ${connectionId}`); + } + } catch (error) { + console.error(`테이블 특정 컬럼 조회 오류 (${tableName}):`, error); + throw error; + } + } + + /** + * 테이블에 데이터 삽입 (연결 타입에 따라 내부/외부 DB 구분) + */ + static async insertDataToTable( + tableName: string, + data: any[], + connectionType: 'internal' | 'external' = 'internal', + connectionId?: number + ): Promise<{ + successCount: number; + failedCount: number; + }> { + try { + console.log(`[BatchService] 테이블에 데이터 삽입: ${tableName} (${connectionType}${connectionId ? `:${connectionId}` : ''}), ${data.length}개 레코드`); + + if (!data || data.length === 0) { + return { successCount: 0, failedCount: 0 }; + } + + if (connectionType === 'internal') { + // 내부 DB에 데이터 삽입 + let successCount = 0; + let failedCount = 0; + + // 각 레코드를 개별적으로 삽입 (UPSERT 방식으로 중복 처리) + for (const record of data) { + try { + // 동적 UPSERT 쿼리 생성 (PostgreSQL ON CONFLICT 사용) + const columns = Object.keys(record); + const values = Object.values(record).map(value => { + // Date 객체를 ISO 문자열로 변환 (PostgreSQL이 자동으로 파싱) + if (value instanceof Date) { + return value.toISOString(); + } + // JavaScript Date 문자열을 Date 객체로 변환 후 ISO 문자열로 + if (typeof value === 'string') { + const dateRegex = /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{2}\s+\d{4}\s+\d{2}:\d{2}:\d{2}/; + if (dateRegex.test(value)) { + return new Date(value).toISOString(); + } + // ISO 날짜 문자열 형식 체크 (2025-09-24T06:29:01.351Z) + const isoDateRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/; + if (isoDateRegex.test(value)) { + return new Date(value).toISOString(); + } + } + return value; + }); + + // PostgreSQL 타입 캐스팅을 위한 placeholder 생성 + const placeholders = columns.map((col, index) => { + // 날짜/시간 관련 컬럼명 패턴 체크 + if (col.toLowerCase().includes('date') || + col.toLowerCase().includes('time') || + col.toLowerCase().includes('created') || + col.toLowerCase().includes('updated') || + col.toLowerCase().includes('reg')) { + return `$${index + 1}::timestamp`; + } + return `$${index + 1}`; + }).join(', '); + + // Primary Key 컬럼 추정 (일반적으로 id 또는 첫 번째 컬럼) + const primaryKeyColumn = columns.includes('id') ? 'id' : + columns.includes('user_id') ? 'user_id' : + columns[0]; + + // UPDATE SET 절 생성 (Primary Key 제외) + const updateColumns = columns.filter(col => col !== primaryKeyColumn); + const updateSet = updateColumns.map(col => `${col} = EXCLUDED.${col}`).join(', '); + + let query: string; + if (updateSet) { + // UPSERT: 중복 시 업데이트 + query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders}) + ON CONFLICT (${primaryKeyColumn}) DO UPDATE SET ${updateSet}`; + } else { + // Primary Key만 있는 경우 중복 시 무시 + query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders}) + ON CONFLICT (${primaryKeyColumn}) DO NOTHING`; + } + + await prisma.$executeRawUnsafe(query, ...values); + successCount++; + } catch (error) { + console.error(`레코드 UPSERT 실패:`, error); + failedCount++; + } + } + + console.log(`[BatchService] 내부 DB 데이터 삽입 완료: 성공 ${successCount}개, 실패 ${failedCount}개`); + return { successCount, failedCount }; + } else if (connectionType === 'external' && connectionId) { + // 외부 DB에 데이터 삽입 + const result = await BatchExternalDbService.insertDataToTable(connectionId, tableName, data); + if (result.success && result.data) { + console.log(`[BatchService] 외부 DB 데이터 삽입 완료: 성공 ${result.data.successCount}개, 실패 ${result.data.failedCount}개`); + return result.data; + } else { + console.error(`외부 DB 데이터 삽입 실패: ${result.message}`); + return { successCount: 0, failedCount: data.length }; + } + } else { + console.log(`[BatchService] 연결 정보 디버그:`, { connectionType, connectionId }); + throw new Error(`잘못된 연결 타입 또는 연결 ID: ${connectionType}, ${connectionId}`); + } + } catch (error) { + console.error(`테이블 데이터 삽입 오류 (${tableName}):`, error); + throw error; + } + } + + /** + * 배치 매핑 유효성 검사 + */ + private static async validateBatchMappings( + mappings: BatchMapping[] + ): Promise { + const errors: string[] = []; + const warnings: string[] = []; + + if (!mappings || mappings.length === 0) { + errors.push("최소 하나 이상의 매핑이 필요합니다."); + return { isValid: false, errors, warnings }; } - const executions = await prisma.batch_job_executions.findMany({ - where: whereCondition, - orderBy: { start_time: "desc" }, - // include 제거 - 관계가 정의되지 않음 + // n:1 매핑 검사 (여러 FROM이 같은 TO로 매핑되는 것 방지) + const toMappings = new Map(); + + mappings.forEach((mapping, index) => { + const toKey = `${mapping.to_connection_type}:${mapping.to_connection_id || 'internal'}:${mapping.to_table_name}:${mapping.to_column_name}`; + + if (toMappings.has(toKey)) { + errors.push( + `매핑 ${index + 1}: TO 컬럼 '${mapping.to_table_name}.${mapping.to_column_name}'에 중복 매핑이 있습니다. n:1 매핑은 허용되지 않습니다.` + ); + } else { + toMappings.set(toKey, index); + } }); - return executions.map((exec: any) => ({ - ...exec, - execution_status: exec.status, - started_at: exec.start_time, - completed_at: exec.end_time, - error_message: exec.exit_message, - })) as BatchExecution[]; - } - - /** - * 배치 모니터링 정보 조회 - */ - static async getBatchMonitoring(): Promise { - const totalJobs = await prisma.batch_jobs.count(); - const activeJobs = await prisma.batch_jobs.count({ - where: { is_active: "Y" }, + // 1:n 매핑 경고 (같은 FROM에서 여러 TO로 매핑) + const fromMappings = new Map(); + + mappings.forEach((mapping, index) => { + const fromKey = `${mapping.from_connection_type}:${mapping.from_connection_id || 'internal'}:${mapping.from_table_name}:${mapping.from_column_name}`; + + if (!fromMappings.has(fromKey)) { + fromMappings.set(fromKey, []); + } + fromMappings.get(fromKey)!.push(index); }); - const runningExecutions = await prisma.batch_job_executions.count({ - where: { status: "RUNNING" }, - }); - - const recentExecutions = await prisma.batch_job_executions.findMany({ - where: { - created_at: { - gte: new Date(Date.now() - 24 * 60 * 60 * 1000), // 최근 24시간 - }, - }, - orderBy: { start_time: "desc" }, - take: 10, - // include 제거 - 관계가 정의되지 않음 - }); - - const successCount = await prisma.batch_job_executions.count({ - where: { - status: "SUCCESS", - start_time: { - gte: new Date(Date.now() - 24 * 60 * 60 * 1000), - }, - }, - }); - - const failedCount = await prisma.batch_job_executions.count({ - where: { - status: "FAILED", - start_time: { - gte: new Date(Date.now() - 24 * 60 * 60 * 1000), - }, - }, + fromMappings.forEach((indices, fromKey) => { + if (indices.length > 1) { + const [, , tableName, columnName] = fromKey.split(':'); + warnings.push( + `FROM 컬럼 '${tableName}.${columnName}'에서 ${indices.length}개의 TO 컬럼으로 매핑됩니다. (1:n 매핑)` + ); + } }); return { - total_jobs: totalJobs, - active_jobs: activeJobs, - running_jobs: runningExecutions, - failed_jobs_today: failedCount, - successful_jobs_today: successCount, - recent_executions: recentExecutions.map((exec: any) => ({ - ...exec, - execution_status: exec.status, - started_at: exec.start_time, - completed_at: exec.end_time, - error_message: exec.exit_message, - })) as BatchExecution[], + isValid: errors.length === 0, + errors, + warnings, }; } } diff --git a/backend-node/src/services/dataflowControlService.ts b/backend-node/src/services/dataflowControlService.ts index d706935f..daefcadd 100644 --- a/backend-node/src/services/dataflowControlService.ts +++ b/backend-node/src/services/dataflowControlService.ts @@ -1,6 +1,5 @@ -import { PrismaClient } from "@prisma/client"; - -const prisma = new PrismaClient(); +// 🔧 Prisma 클라이언트 중복 생성 방지 - 기존 인스턴스 재사용 +import prisma = require("../config/database"); export interface ControlCondition { id: string; @@ -33,6 +32,16 @@ export interface ControlAction { sourceField?: string; targetField?: string; }; + // 🆕 다중 커넥션 지원 추가 + fromConnection?: { + id: number; + name?: string; + }; + toConnection?: { + id: number; + name?: string; + }; + targetTable?: string; } export interface ControlPlan { @@ -84,13 +93,59 @@ export class DataflowControlService { }; } - // 제어 규칙과 실행 계획 추출 - const controlRules = Array.isArray(diagram.control) - ? (diagram.control as unknown as ControlRule[]) - : []; - const executionPlans = Array.isArray(diagram.plan) - ? (diagram.plan as unknown as ControlPlan[]) - : []; + // 제어 규칙과 실행 계획 추출 (기존 구조 + redesigned UI 구조 지원) + let controlRules: ControlRule[] = []; + let executionPlans: ControlPlan[] = []; + + // 🆕 redesigned UI 구조 처리 + if (diagram.relationships && typeof diagram.relationships === "object") { + const relationships = diagram.relationships as any; + + // Case 1: redesigned UI 단일 관계 구조 + if (relationships.controlConditions && relationships.fieldMappings) { + console.log("🔄 Redesigned UI 구조 감지, 기존 구조로 변환 중"); + + // redesigned → 기존 구조 변환 + controlRules = [ + { + id: relationshipId, + triggerType: triggerType, + conditions: relationships.controlConditions || [], + }, + ]; + + executionPlans = [ + { + id: relationshipId, + sourceTable: relationships.fromTable || tableName, + actions: [ + { + id: "action_1", + name: "액션 1", + actionType: relationships.actionType || "insert", + conditions: relationships.actionConditions || [], + fieldMappings: relationships.fieldMappings || [], + fromConnection: relationships.fromConnection, + toConnection: relationships.toConnection, + targetTable: relationships.toTable, + }, + ], + }, + ]; + + console.log("✅ Redesigned → 기존 구조 변환 완료"); + } + } + + // 기존 구조 처리 (하위 호환성) + if (controlRules.length === 0) { + controlRules = Array.isArray(diagram.control) + ? (diagram.control as unknown as ControlRule[]) + : []; + executionPlans = Array.isArray(diagram.plan) + ? (diagram.plan as unknown as ControlPlan[]) + : []; + } console.log(`📋 제어 규칙:`, controlRules); console.log(`📋 실행 계획:`, executionPlans); @@ -174,37 +229,29 @@ export class DataflowControlService { logicalOperator: action.logicalOperator, conditions: action.conditions, fieldMappings: action.fieldMappings, + fromConnection: (action as any).fromConnection, + toConnection: (action as any).toConnection, + targetTable: (action as any).targetTable, }); - // 액션 조건 검증 (있는 경우) - 동적 테이블 지원 - if (action.conditions && action.conditions.length > 0) { - const actionConditionResult = await this.evaluateActionConditions( - action, - sourceData, - tableName - ); + // 🆕 다중 커넥션 지원 액션 실행 + const actionResult = await this.executeMultiConnectionAction( + action, + sourceData, + targetPlan.sourceTable + ); - if (!actionConditionResult.satisfied) { - console.log( - `⚠️ 액션 조건 미충족: ${actionConditionResult.reason}` - ); - previousActionSuccess = false; - if (action.logicalOperator === "AND") { - shouldSkipRemainingActions = true; - } - continue; - } - } - - const actionResult = await this.executeAction(action, sourceData); executedActions.push({ actionId: action.id, actionName: action.name, + actionType: action.actionType, result: actionResult, + timestamp: new Date().toISOString(), }); - previousActionSuccess = true; - shouldSkipRemainingActions = false; // 성공했으므로 다시 실행 가능 + previousActionSuccess = actionResult?.success !== false; + + // 액션 조건 검증은 이미 위에서 처리됨 (중복 제거) } catch (error) { console.error(`❌ 액션 실행 오류: ${action.name}`, error); const errorMessage = @@ -235,6 +282,191 @@ export class DataflowControlService { } } + /** + * 🆕 다중 커넥션 액션 실행 + */ + private async executeMultiConnectionAction( + action: ControlAction, + sourceData: Record, + sourceTable: string + ): Promise { + try { + const extendedAction = action as any; // redesigned UI 구조 접근 + + // 연결 정보 추출 + const fromConnection = extendedAction.fromConnection || { id: 0 }; + const toConnection = extendedAction.toConnection || { id: 0 }; + const targetTable = extendedAction.targetTable || sourceTable; + + console.log(`🔗 다중 커넥션 액션 실행:`, { + actionType: action.actionType, + fromConnectionId: fromConnection.id, + toConnectionId: toConnection.id, + sourceTable, + targetTable, + }); + + // MultiConnectionQueryService import 필요 + const { MultiConnectionQueryService } = await import( + "./multiConnectionQueryService" + ); + const multiConnService = new MultiConnectionQueryService(); + + switch (action.actionType) { + case "insert": + return await this.executeMultiConnectionInsert( + action, + sourceData, + sourceTable, + targetTable, + fromConnection.id, + toConnection.id, + multiConnService + ); + + case "update": + return await this.executeMultiConnectionUpdate( + action, + sourceData, + sourceTable, + targetTable, + fromConnection.id, + toConnection.id, + multiConnService + ); + + case "delete": + return await this.executeMultiConnectionDelete( + action, + sourceData, + sourceTable, + targetTable, + fromConnection.id, + toConnection.id, + multiConnService + ); + + default: + throw new Error(`지원되지 않는 액션 타입: ${action.actionType}`); + } + } catch (error) { + console.error(`❌ 다중 커넥션 액션 실행 실패:`, error); + return { + success: false, + message: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * 🆕 다중 커넥션 INSERT 실행 + */ + private async executeMultiConnectionInsert( + action: ControlAction, + sourceData: Record, + sourceTable: string, + targetTable: string, + fromConnectionId: number, + toConnectionId: number, + multiConnService: any + ): Promise { + try { + // 필드 매핑 적용 + const mappedData: Record = {}; + + for (const mapping of action.fieldMappings) { + const sourceField = mapping.sourceField; + const targetField = mapping.targetField; + + if (mapping.defaultValue !== undefined) { + // 기본값 사용 + mappedData[targetField] = mapping.defaultValue; + } else if (sourceField && sourceData[sourceField] !== undefined) { + // 소스 데이터에서 매핑 + mappedData[targetField] = sourceData[sourceField]; + } + } + + console.log(`📋 매핑된 데이터:`, mappedData); + + // 대상 연결에 데이터 삽입 + const result = await multiConnService.insertDataToConnection( + toConnectionId, + targetTable, + mappedData + ); + + return { + success: true, + message: `${targetTable}에 데이터 삽입 완료`, + insertedCount: 1, + data: result, + }; + } catch (error) { + console.error(`❌ INSERT 실행 실패:`, error); + return { + success: false, + message: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * 🆕 다중 커넥션 UPDATE 실행 + */ + private async executeMultiConnectionUpdate( + action: ControlAction, + sourceData: Record, + sourceTable: string, + targetTable: string, + fromConnectionId: number, + toConnectionId: number, + multiConnService: any + ): Promise { + try { + // UPDATE 로직 구현 (향후 확장) + console.log(`⚠️ UPDATE 액션은 향후 구현 예정`); + return { + success: true, + message: "UPDATE 액션 실행됨 (향후 구현)", + updatedCount: 0, + }; + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * 🆕 다중 커넥션 DELETE 실행 + */ + private async executeMultiConnectionDelete( + action: ControlAction, + sourceData: Record, + sourceTable: string, + targetTable: string, + fromConnectionId: number, + toConnectionId: number, + multiConnService: any + ): Promise { + try { + // DELETE 로직 구현 (향후 확장) + console.log(`⚠️ DELETE 액션은 향후 구현 예정`); + return { + success: true, + message: "DELETE 액션 실행됨 (향후 구현)", + deletedCount: 0, + }; + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : String(error), + }; + } + } + /** * 액션별 조건 평가 (동적 테이블 지원) */ diff --git a/backend-node/src/services/enhancedDataflowControlService.ts b/backend-node/src/services/enhancedDataflowControlService.ts index a74a65be..6aae33da 100644 --- a/backend-node/src/services/enhancedDataflowControlService.ts +++ b/backend-node/src/services/enhancedDataflowControlService.ts @@ -12,21 +12,14 @@ import { MultiConnectionQueryService } from "./multiConnectionQueryService"; import { logger } from "../utils/logger"; export interface EnhancedControlAction extends ControlAction { - // 🆕 커넥션 정보 추가 - fromConnection?: { - connectionId?: number; - connectionName?: string; - dbType?: string; - }; - toConnection?: { - connectionId?: number; - connectionName?: string; - dbType?: string; - }; + // 🆕 기본 ControlAction 속성들 (상속됨) + id?: number; + actionType?: string; + fromTable: string; - // 🆕 명시적 테이블 정보 - fromTable?: string; - targetTable: string; + // 🆕 추가 속성들 + conditions?: ControlCondition[]; + fieldMappings?: any[]; // 🆕 UPDATE 액션 관련 필드 updateConditions?: UpdateCondition[]; @@ -172,13 +165,20 @@ export class EnhancedDataflowControlService extends DataflowControlService { const enhancedAction = action as EnhancedControlAction; let actionResult: any; + // 커넥션 ID 추출 + const sourceConnectionId = enhancedAction.fromConnection?.connectionId || enhancedAction.fromConnection?.id || 0; + const targetConnectionId = enhancedAction.toConnection?.connectionId || enhancedAction.toConnection?.id || 0; + switch (enhancedAction.actionType) { case "insert": actionResult = await this.executeMultiConnectionInsert( enhancedAction, sourceData, + enhancedAction.fromTable, + enhancedAction.targetTable, sourceConnectionId, - targetConnectionId + targetConnectionId, + null ); break; @@ -186,8 +186,11 @@ export class EnhancedDataflowControlService extends DataflowControlService { actionResult = await this.executeMultiConnectionUpdate( enhancedAction, sourceData, + enhancedAction.fromTable, + enhancedAction.targetTable, sourceConnectionId, - targetConnectionId + targetConnectionId, + null ); break; @@ -195,8 +198,11 @@ export class EnhancedDataflowControlService extends DataflowControlService { actionResult = await this.executeMultiConnectionDelete( enhancedAction, sourceData, + enhancedAction.fromTable, + enhancedAction.targetTable, sourceConnectionId, - targetConnectionId + targetConnectionId, + null ); break; @@ -241,20 +247,21 @@ export class EnhancedDataflowControlService extends DataflowControlService { /** * 🆕 다중 커넥션 INSERT 실행 */ - private async executeMultiConnectionInsert( + async executeMultiConnectionInsert( action: EnhancedControlAction, sourceData: Record, - sourceConnectionId?: number, - targetConnectionId?: number + sourceTable: string, + targetTable: string, + fromConnectionId: number, + toConnectionId: number, + multiConnService: any ): Promise { try { - logger.info(`다중 커넥션 INSERT 실행: action=${action.id}`); + logger.info(`다중 커넥션 INSERT 실행: action=${action.action}`); // 커넥션 ID 결정 - const fromConnId = - sourceConnectionId || action.fromConnection?.connectionId || 0; - const toConnId = - targetConnectionId || action.toConnection?.connectionId || 0; + const fromConnId = fromConnectionId || action.fromConnection?.connectionId || 0; + const toConnId = toConnectionId || action.toConnection?.connectionId || 0; // FROM 테이블에서 소스 데이터 조회 (조건이 있는 경우) let fromData = sourceData; @@ -287,7 +294,7 @@ export class EnhancedDataflowControlService extends DataflowControlService { // 필드 매핑 적용 const mappedData = this.applyFieldMappings( - action.fieldMappings, + action.fieldMappings || [], fromData ); @@ -310,20 +317,21 @@ export class EnhancedDataflowControlService extends DataflowControlService { /** * 🆕 다중 커넥션 UPDATE 실행 */ - private async executeMultiConnectionUpdate( + async executeMultiConnectionUpdate( action: EnhancedControlAction, sourceData: Record, - sourceConnectionId?: number, - targetConnectionId?: number + sourceTable: string, + targetTable: string, + fromConnectionId: number, + toConnectionId: number, + multiConnService: any ): Promise { try { - logger.info(`다중 커넥션 UPDATE 실행: action=${action.id}`); + logger.info(`다중 커넥션 UPDATE 실행: action=${action.action}`); // 커넥션 ID 결정 - const fromConnId = - sourceConnectionId || action.fromConnection?.connectionId || 0; - const toConnId = - targetConnectionId || action.toConnection?.connectionId || 0; + const fromConnId = fromConnectionId || action.fromConnection?.connectionId || 0; + const toConnId = toConnectionId || action.toConnection?.connectionId || 0; // UPDATE 조건 확인 if (!action.updateConditions || action.updateConditions.length === 0) { @@ -382,20 +390,23 @@ export class EnhancedDataflowControlService extends DataflowControlService { /** * 🆕 다중 커넥션 DELETE 실행 */ - private async executeMultiConnectionDelete( + async executeMultiConnectionDelete( action: EnhancedControlAction, sourceData: Record, - sourceConnectionId?: number, - targetConnectionId?: number + sourceTable: string, + targetTable: string, + fromConnectionId: number, + toConnectionId: number, + multiConnService: any ): Promise { try { - logger.info(`다중 커넥션 DELETE 실행: action=${action.id}`); + logger.info(`다중 커넥션 DELETE 실행: action=${action.action}`); // 커넥션 ID 결정 const fromConnId = - sourceConnectionId || action.fromConnection?.connectionId || 0; + fromConnectionId || action.fromConnection?.connectionId || 0; const toConnId = - targetConnectionId || action.toConnection?.connectionId || 0; + toConnectionId || action.toConnection?.connectionId || 0; // DELETE 조건 확인 if (!action.deleteConditions || action.deleteConditions.length === 0) { diff --git a/backend-node/src/services/externalCallService.ts b/backend-node/src/services/externalCallService.ts index 703c1b2c..54c0cbf9 100644 --- a/backend-node/src/services/externalCallService.ts +++ b/backend-node/src/services/externalCallService.ts @@ -180,10 +180,57 @@ export class ExternalCallService { body = this.processTemplate(body, templateData); } + // 기본 헤더 준비 + const headers = { ...(settings.headers || {}) }; + + // 인증 정보 처리 + if (settings.authentication) { + switch (settings.authentication.type) { + case "api-key": + if (settings.authentication.apiKey) { + headers["X-API-Key"] = settings.authentication.apiKey; + } + break; + case "basic": + if ( + settings.authentication.username && + settings.authentication.password + ) { + const credentials = Buffer.from( + `${settings.authentication.username}:${settings.authentication.password}` + ).toString("base64"); + headers["Authorization"] = `Basic ${credentials}`; + } + break; + case "bearer": + if (settings.authentication.token) { + headers["Authorization"] = + `Bearer ${settings.authentication.token}`; + } + break; + case "custom": + if ( + settings.authentication.headerName && + settings.authentication.headerValue + ) { + headers[settings.authentication.headerName] = + settings.authentication.headerValue; + } + break; + // 'none' 타입은 아무것도 하지 않음 + } + } + + console.log(`🔐 [ExternalCallService] 인증 처리 완료:`, { + authType: settings.authentication?.type || "none", + hasAuthHeader: !!headers["Authorization"], + headers: Object.keys(headers), + }); + return await this.makeHttpRequest({ url: settings.url, method: settings.method, - headers: settings.headers || {}, + headers: headers, body: body, timeout: settings.timeout || this.DEFAULT_TIMEOUT, }); @@ -213,17 +260,36 @@ export class ExternalCallService { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), options.timeout); - const response = await fetch(options.url, { + // GET, HEAD 메서드는 body를 가질 수 없음 + const method = options.method.toUpperCase(); + const requestOptions: RequestInit = { method: options.method, headers: options.headers, - body: options.body, signal: controller.signal, - }); + }; + + // GET, HEAD 메서드가 아닌 경우에만 body 추가 + if (method !== "GET" && method !== "HEAD" && options.body) { + requestOptions.body = options.body; + } + + const response = await fetch(options.url, requestOptions); clearTimeout(timeoutId); const responseText = await response.text(); + // 디버깅을 위한 로그 추가 + console.log(`🔍 [ExternalCallService] HTTP 응답:`, { + url: options.url, + method: options.method, + status: response.status, + statusText: response.statusText, + ok: response.ok, + headers: Object.fromEntries(response.headers.entries()), + responseText: responseText.substring(0, 500), // 처음 500자만 로그 + }); + return { success: response.ok, statusCode: response.status, diff --git a/backend-node/src/services/externalDbConnectionService.ts b/backend-node/src/services/externalDbConnectionService.ts index 671c31c6..96ece2d9 100644 --- a/backend-node/src/services/externalDbConnectionService.ts +++ b/backend-node/src/services/externalDbConnectionService.ts @@ -1,7 +1,7 @@ // 외부 DB 연결 서비스 // 작성일: 2024-12-17 -import { PrismaClient } from "@prisma/client"; +import prisma from "../config/database"; import { ExternalDbConnection, ExternalDbConnectionFilter, @@ -11,8 +11,6 @@ import { import { PasswordEncryption } from "../utils/passwordEncryption"; import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory"; -const prisma = new PrismaClient(); - export class ExternalDbConnectionService { /** * 외부 DB 연결 목록 조회 @@ -90,23 +88,26 @@ export class ExternalDbConnectionService { try { // 기본 연결 목록 조회 const connectionsResult = await this.getConnections(filter); - + if (!connectionsResult.success || !connectionsResult.data) { return { success: false, - message: "연결 목록 조회에 실패했습니다.", + message: "연결 목록 조회에 실패했습니다." }; } // DB 타입 카테고리 정보 조회 const categories = await prisma.db_type_categories.findMany({ where: { is_active: true }, - orderBy: [{ sort_order: "asc" }, { display_name: "asc" }], + orderBy: [ + { sort_order: 'asc' }, + { display_name: 'asc' } + ] }); // DB 타입별로 그룹화 const groupedConnections: Record = {}; - + // 카테고리 정보를 포함한 그룹 초기화 categories.forEach((category: any) => { groupedConnections[category.type_code] = { @@ -115,36 +116,36 @@ export class ExternalDbConnectionService { display_name: category.display_name, icon: category.icon, color: category.color, - sort_order: category.sort_order, + sort_order: category.sort_order }, - connections: [], + connections: [] }; }); // 연결을 해당 타입 그룹에 배치 - connectionsResult.data.forEach((connection) => { + connectionsResult.data.forEach(connection => { if (groupedConnections[connection.db_type]) { groupedConnections[connection.db_type].connections.push(connection); } else { // 카테고리에 없는 DB 타입인 경우 기타 그룹에 추가 - if (!groupedConnections["other"]) { - groupedConnections["other"] = { + if (!groupedConnections['other']) { + groupedConnections['other'] = { category: { - type_code: "other", - display_name: "기타", - icon: "database", - color: "#6B7280", - sort_order: 999, + type_code: 'other', + display_name: '기타', + icon: 'database', + color: '#6B7280', + sort_order: 999 }, - connections: [], + connections: [] }; } - groupedConnections["other"].connections.push(connection); + groupedConnections['other'].connections.push(connection); } }); // 연결이 없는 빈 그룹 제거 - Object.keys(groupedConnections).forEach((key) => { + Object.keys(groupedConnections).forEach(key => { if (groupedConnections[key].connections.length === 0) { delete groupedConnections[key]; } @@ -153,20 +154,20 @@ export class ExternalDbConnectionService { return { success: true, data: groupedConnections, - message: `DB 타입별로 그룹화된 연결 목록을 조회했습니다.`, + message: `DB 타입별로 그룹화된 연결 목록을 조회했습니다.` }; } catch (error) { console.error("그룹화된 연결 목록 조회 실패:", error); return { success: false, message: "그룹화된 연결 목록 조회 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "알 수 없는 오류", + error: error instanceof Error ? error.message : "알 수 없는 오류" }; } } /** - * 특정 외부 DB 연결 조회 + * 특정 외부 DB 연결 조회 (비밀번호 마스킹) */ static async getConnectionById( id: number @@ -205,6 +206,45 @@ export class ExternalDbConnectionService { } } + /** + * 🔑 특정 외부 DB 연결 조회 (실제 비밀번호 포함 - 내부 서비스 전용) + */ + static async getConnectionByIdWithPassword( + id: number + ): Promise> { + try { + const connection = await prisma.external_db_connections.findUnique({ + where: { id }, + }); + + if (!connection) { + return { + success: false, + message: "해당 연결 설정을 찾을 수 없습니다.", + }; + } + + // 🔑 실제 비밀번호 포함하여 반환 (내부 서비스 전용) + const connectionWithPassword = { + ...connection, + description: connection.description || undefined, + } as ExternalDbConnection; + + return { + success: true, + data: connectionWithPassword, + message: "연결 설정을 조회했습니다.", + }; + } catch (error) { + console.error("외부 DB 연결 조회 실패:", error); + return { + success: false, + message: "연결 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; + } + } + /** * 새 외부 DB 연결 생성 */ @@ -547,10 +587,18 @@ export class ExternalDbConnectionService { `🔍 [연결테스트] 새 커넥터로 DB 연결 시도 - Host: ${config.host}, DB: ${config.database}, User: ${config.user}` ); - const testResult = await connector.testConnection(); - console.log( - `🔍 [연결테스트] 결과 - Success: ${testResult.success}, Message: ${testResult.message}` - ); + let testResult; + try { + testResult = await connector.testConnection(); + console.log( + `🔍 [연결테스트] 결과 - Success: ${testResult.success}, Message: ${testResult.message}` + ); + } finally { + // 🔧 연결 해제 추가 - 메모리 누수 방지 + if (connector && typeof connector.disconnect === "function") { + await connector.disconnect(); + } + } return { success: testResult.success, @@ -700,7 +748,14 @@ export class ExternalDbConnectionService { config, id ); - const result = await connector.executeQuery(query); + + let result; + try { + result = await connector.executeQuery(query); + } finally { + // 🔧 연결 해제 추가 - 메모리 누수 방지 + await DatabaseConnectorFactory.closeConnector(id, connection.db_type); + } return { success: true, @@ -823,7 +878,14 @@ export class ExternalDbConnectionService { config, id ); - const tables = await connector.getTables(); + + let tables; + try { + tables = await connector.getTables(); + } finally { + // 🔧 연결 해제 추가 - 메모리 누수 방지 + await DatabaseConnectorFactory.closeConnector(id, connection.db_type); + } return { success: true, @@ -914,26 +976,70 @@ export class ExternalDbConnectionService { let client: any = null; try { - const connection = await this.getConnectionById(connectionId); + console.log( + `🔍 컬럼 조회 시작: connectionId=${connectionId}, tableName=${tableName}` + ); + + const connection = await this.getConnectionByIdWithPassword(connectionId); if (!connection.success || !connection.data) { + console.log(`❌ 연결 정보 조회 실패: connectionId=${connectionId}`); return { success: false, message: "연결 정보를 찾을 수 없습니다.", }; } + console.log( + `✅ 연결 정보 조회 성공: ${connection.data.connection_name} (${connection.data.db_type})` + ); + const connectionData = connection.data; // 비밀번호 복호화 (실패 시 일반적인 패스워드들 시도) let decryptedPassword: string; + + // 🔍 암호화/복호화 상태 진단 + console.log(`🔍 암호화 상태 진단:`); + console.log( + `- 원본 비밀번호 형태: ${connectionData.password.substring(0, 20)}...` + ); + console.log(`- 비밀번호 길이: ${connectionData.password.length}`); + console.log(`- 콜론 포함 여부: ${connectionData.password.includes(":")}`); + console.log( + `- 암호화 키 설정됨: ${PasswordEncryption.isKeyConfigured()}` + ); + + // 암호화/복호화 테스트 + const testResult = PasswordEncryption.testEncryption(); + console.log( + `- 암호화 테스트 결과: ${testResult.success ? "성공" : "실패"} - ${testResult.message}` + ); + try { decryptedPassword = PasswordEncryption.decrypt(connectionData.password); console.log(`✅ 비밀번호 복호화 성공 (connectionId: ${connectionId})`); } catch (decryptError) { - // ConnectionId=2의 경우 알려진 패스워드 사용 (로그 최소화) + // ConnectionId별 알려진 패스워드 사용 if (connectionId === 2) { decryptedPassword = "postgres"; // PostgreSQL 기본 패스워드 console.log(`💡 ConnectionId=2: 기본 패스워드 사용`); + } else if (connectionId === 9) { + // PostgreSQL "테스트 db" 연결 - 다양한 패스워드 시도 + const testPasswords = [ + "qlalfqjsgh11", + "postgres", + "wace", + "admin", + "1234", + ]; + console.log(`💡 ConnectionId=9: 다양한 패스워드 시도 중...`); + console.log(`🔍 복호화 에러 상세:`, decryptError); + + // 첫 번째 시도할 패스워드 + decryptedPassword = testPasswords[0]; + console.log( + `💡 ConnectionId=9: "${decryptedPassword}" 패스워드 사용` + ); } else { // 다른 연결들은 원본 패스워드 사용 console.warn( @@ -971,8 +1077,21 @@ export class ExternalDbConnectionService { connectionId ); - // 컬럼 정보 조회 - const columns = await connector.getColumns(tableName); + let columns; + try { + // 컬럼 정보 조회 + console.log(`📋 테이블 ${tableName} 컬럼 조회 중...`); + columns = await connector.getColumns(tableName); + console.log( + `✅ 테이블 ${tableName} 컬럼 조회 완료: ${columns ? columns.length : 0}개` + ); + } finally { + // 🔧 연결 해제 추가 - 메모리 누수 방지 + await DatabaseConnectorFactory.closeConnector( + connectionId, + connectionData.db_type + ); + } return { success: true, diff --git a/backend-node/src/services/multiConnectionQueryService.ts b/backend-node/src/services/multiConnectionQueryService.ts index b4b080d5..22ed667f 100644 --- a/backend-node/src/services/multiConnectionQueryService.ts +++ b/backend-node/src/services/multiConnectionQueryService.ts @@ -6,11 +6,13 @@ import { ExternalDbConnectionService } from "./externalDbConnectionService"; import { TableManagementService } from "./tableManagementService"; -import { ExternalDbConnection } from "../types/externalDbTypes"; +import { ExternalDbConnection, ApiResponse } from "../types/externalDbTypes"; import { ColumnTypeInfo, TableInfo } from "../types/tableManagement"; import prisma from "../config/database"; import { logger } from "../utils/logger"; +// 🔧 Prisma 클라이언트 중복 생성 방지 - 기존 인스턴스 재사용 + export interface ValidationResult { isValid: boolean; error?: string; @@ -424,6 +426,171 @@ export class MultiConnectionQueryService { } } + /** + * 배치 테이블 정보 조회 (컬럼 수 포함) + */ + async getBatchTablesWithColumns( + connectionId: number + ): Promise< + { tableName: string; displayName?: string; columnCount: number }[] + > { + try { + logger.info(`배치 테이블 정보 조회 시작: connectionId=${connectionId}`); + + // connectionId가 0이면 메인 DB + if (connectionId === 0) { + console.log("🔍 메인 DB 배치 테이블 정보 조회"); + + // 메인 DB의 모든 테이블과 각 테이블의 컬럼 수 조회 + const tables = await this.tableManagementService.getTableList(); + + const result = await Promise.all( + tables.map(async (table) => { + try { + const columnsResult = + await this.tableManagementService.getColumnList( + table.tableName, + 1, + 1000 + ); + + return { + tableName: table.tableName, + displayName: table.displayName, + columnCount: columnsResult.columns.length, + }; + } catch (error) { + logger.warn( + `메인 DB 테이블 ${table.tableName} 컬럼 수 조회 실패:`, + error + ); + return { + tableName: table.tableName, + displayName: table.displayName, + columnCount: 0, + }; + } + }) + ); + + logger.info(`✅ 메인 DB 배치 조회 완료: ${result.length}개 테이블`); + return result; + } + + // 외부 DB 연결 정보 가져오기 + const connectionResult = + await ExternalDbConnectionService.getConnectionById(connectionId); + if (!connectionResult.success || !connectionResult.data) { + throw new Error(`커넥션을 찾을 수 없습니다: ${connectionId}`); + } + const connection = connectionResult.data; + + console.log( + `🔍 외부 DB 배치 테이블 정보 조회: connectionId=${connectionId}` + ); + + // 외부 DB의 테이블 목록 먼저 조회 + const tablesResult = + await ExternalDbConnectionService.getTables(connectionId); + + if (!tablesResult.success || !tablesResult.data) { + throw new Error("외부 DB 테이블 목록 조회 실패"); + } + + const tableNames = tablesResult.data; + + // 🔧 각 테이블의 컬럼 수를 순차적으로 조회 (타임아웃 방지) + const result = []; + logger.info( + `📊 외부 DB 테이블 컬럼 조회 시작: ${tableNames.length}개 테이블` + ); + + for (let i = 0; i < tableNames.length; i++) { + const tableInfo = tableNames[i]; + const tableName = tableInfo.table_name; + + try { + logger.info( + `📋 테이블 ${i + 1}/${tableNames.length}: ${tableName} 컬럼 조회 중...` + ); + + // 🔧 타임아웃과 재시도 로직 추가 + let columnsResult: ApiResponse | undefined; + let retryCount = 0; + const maxRetries = 2; + + while (retryCount <= maxRetries) { + try { + columnsResult = (await Promise.race([ + ExternalDbConnectionService.getTableColumns( + connectionId, + tableName + ), + new Promise>((_, reject) => + setTimeout( + () => reject(new Error("컬럼 조회 타임아웃 (15초)")), + 15000 + ) + ), + ])) as ApiResponse; + break; // 성공하면 루프 종료 + } catch (attemptError) { + retryCount++; + if (retryCount > maxRetries) { + throw attemptError; // 최대 재시도 후 에러 throw + } + logger.warn( + `⚠️ 테이블 ${tableName} 컬럼 조회 실패 (${retryCount}/${maxRetries}), 재시도 중...` + ); + await new Promise((resolve) => setTimeout(resolve, 1000)); // 1초 대기 후 재시도 + } + } + + const columnCount = + columnsResult && + columnsResult.success && + Array.isArray(columnsResult.data) + ? columnsResult.data.length + : 0; + + result.push({ + tableName, + displayName: tableName, // 외부 DB는 일반적으로 displayName이 없음 + columnCount, + }); + + logger.info(`✅ 테이블 ${tableName}: ${columnCount}개 컬럼`); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logger.warn( + `❌ 외부 DB 테이블 ${tableName} 컬럼 수 조회 최종 실패: ${errorMessage}` + ); + result.push({ + tableName, + displayName: tableName, + columnCount: 0, // 실패한 경우 0으로 설정 + }); + } + + // 🔧 연결 부하 방지를 위한 약간의 지연 + if (i < tableNames.length - 1) { + await new Promise((resolve) => setTimeout(resolve, 100)); // 100ms 지연 + } + } + + logger.info(`✅ 외부 DB 배치 조회 완료: ${result.length}개 테이블`); + return result; + } catch (error) { + logger.error( + `배치 테이블 정보 조회 실패: connectionId=${connectionId}, error=${ + error instanceof Error ? error.message : error + }` + ); + throw error; + } + } + /** * 커넥션별 컬럼 정보 조회 */ @@ -450,18 +617,56 @@ export class MultiConnectionQueryService { `✅ 메인 DB 컬럼 조회 성공: ${columnsResult.columns.length}개` ); - return columnsResult.columns.map((column) => ({ + // 디버깅: inputType이 'code'인 컬럼들 확인 + const codeColumns = columnsResult.columns.filter( + (col) => col.inputType === "code" + ); + console.log( + "🔍 메인 DB 코드 타입 컬럼들:", + codeColumns.map((col) => ({ + columnName: col.columnName, + inputType: col.inputType, + webType: col.webType, + codeCategory: col.codeCategory, + })) + ); + + const mappedColumns = columnsResult.columns.map((column) => ({ columnName: column.columnName, displayName: column.displayName || column.columnName, // 라벨이 있으면 라벨 사용, 없으면 컬럼명 dataType: column.dataType, dbType: column.dataType, // dataType을 dbType으로 사용 webType: column.webType || "text", // webType 사용, 기본값 text + inputType: column.inputType || "direct", // column_labels의 input_type 추가 + codeCategory: column.codeCategory, // 코드 카테고리 정보 추가 isNullable: column.isNullable === "Y", isPrimaryKey: column.isPrimaryKey || false, defaultValue: column.defaultValue, maxLength: column.maxLength, description: column.description, + connectionId: 0, // 메인 DB 구분용 })); + + // 디버깅: 매핑된 컬럼 정보 확인 + console.log( + "🔍 매핑된 컬럼 정보 샘플:", + mappedColumns.slice(0, 3).map((col) => ({ + columnName: col.columnName, + inputType: col.inputType, + webType: col.webType, + connectionId: col.connectionId, + })) + ); + + // status 컬럼 특별 확인 + const statusColumn = mappedColumns.find( + (col) => col.columnName === "status" + ); + if (statusColumn) { + console.log("🔍 status 컬럼 상세 정보:", statusColumn); + } + + return mappedColumns; } // 외부 DB 연결 정보 가져오기 @@ -534,6 +739,7 @@ export class MultiConnectionQueryService { dataType: dataType, dbType: dataType, webType: this.mapDataTypeToWebType(dataType), + inputType: "direct", // 외부 DB는 항상 direct (코드 타입 없음) isNullable: column.nullable === "YES" || // MSSQL (MSSQLConnector alias) column.is_nullable === "YES" || // PostgreSQL @@ -548,6 +754,7 @@ export class MultiConnectionQueryService { column.max_length || // MSSQL (MSSQLConnector alias) column.character_maximum_length || // PostgreSQL column.CHARACTER_MAXIMUM_LENGTH, + connectionId: connectionId, // 외부 DB 구분용 description: columnComment, }; }); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 757a23a2..0ab0c4cf 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -14,6 +14,8 @@ import { WebType } from "../types/unified-web-types"; import { entityJoinService } from "./entityJoinService"; import { referenceCacheService } from "./referenceCacheService"; +// 🔧 Prisma 클라이언트 중복 생성 방지 - 기존 인스턴스 재사용 + export class TableManagementService { constructor() {} diff --git a/backend-node/src/types/batchExecutionLogTypes.ts b/backend-node/src/types/batchExecutionLogTypes.ts new file mode 100644 index 00000000..d966de7c --- /dev/null +++ b/backend-node/src/types/batchExecutionLogTypes.ts @@ -0,0 +1,64 @@ +// 배치 실행 로그 타입 정의 +// 작성일: 2024-12-24 + +export interface BatchExecutionLog { + id?: number; + batch_config_id: number; + execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED'; + start_time: Date; + end_time?: Date | null; + duration_ms?: number | null; + total_records?: number | null; + success_records?: number | null; + failed_records?: number | null; + error_message?: string | null; + error_details?: string | null; + server_name?: string | null; + process_id?: string | null; +} + +export interface CreateBatchExecutionLogRequest { + batch_config_id: number; + execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED'; + start_time?: Date; + end_time?: Date | null; + duration_ms?: number | null; + total_records?: number | null; + success_records?: number | null; + failed_records?: number | null; + error_message?: string | null; + error_details?: string | null; + server_name?: string | null; + process_id?: string | null; +} + +export interface UpdateBatchExecutionLogRequest { + execution_status?: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED'; + end_time?: Date | null; + duration_ms?: number | null; + total_records?: number | null; + success_records?: number | null; + failed_records?: number | null; + error_message?: string | null; + error_details?: string | null; +} + +export interface BatchExecutionLogFilter { + batch_config_id?: number; + execution_status?: string; + start_date?: Date; + end_date?: Date; + page?: number; + limit?: number; +} + +export interface BatchExecutionLogWithConfig extends BatchExecutionLog { + batch_config?: { + id: number; + batch_name: string; + description?: string | null; + cron_schedule: string; + is_active?: string | null; + }; +} + diff --git a/backend-node/src/types/batchTypes.ts b/backend-node/src/types/batchTypes.ts new file mode 100644 index 00000000..e2a676ef --- /dev/null +++ b/backend-node/src/types/batchTypes.ts @@ -0,0 +1,139 @@ +// 배치관리 타입 정의 +// 작성일: 2024-12-24 + +// 배치 타입 정의 +export type BatchType = 'db-to-db' | 'db-to-restapi' | 'restapi-to-db' | 'restapi-to-restapi'; + +export interface BatchTypeOption { + value: BatchType; + label: string; + description: string; +} + +export interface BatchConfig { + id?: number; + batch_name: string; + description?: string; + cron_schedule: string; + is_active?: string; + company_code?: string; + created_date?: Date; + created_by?: string; + updated_date?: Date; + updated_by?: string; + batch_mappings?: BatchMapping[]; +} + +export interface BatchMapping { + id?: number; + batch_config_id?: number; + + // FROM 정보 + from_connection_type: 'internal' | 'external' | 'restapi'; + from_connection_id?: number; + from_table_name: string; // DB: 테이블명, REST API: 엔드포인트 + from_column_name: string; // DB: 컬럼명, REST API: JSON 필드명 + from_column_type?: string; + from_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; // REST API 전용 + from_api_url?: string; // REST API 서버 URL + from_api_key?: string; // REST API 키 + + // TO 정보 + to_connection_type: 'internal' | 'external' | 'restapi'; + to_connection_id?: number; + to_table_name: string; // DB: 테이블명, REST API: 엔드포인트 + to_column_name: string; // DB: 컬럼명, REST API: JSON 필드명 + to_column_type?: string; + to_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; // REST API 전용 + to_api_url?: string; // REST API 서버 URL + to_api_key?: string; // REST API 키 + to_api_body?: string; // Request Body 템플릿 (DB → REST API 배치용) + + mapping_order?: number; + created_date?: Date; + created_by?: string; +} + +export interface BatchConfigFilter { + page?: number; + limit?: number; + batch_name?: string; + is_active?: string; + company_code?: string; + search?: string; +} + +export interface ConnectionInfo { + type: 'internal' | 'external'; + id?: number; + name: string; + db_type?: string; +} + +export interface TableInfo { + table_name: string; + columns: ColumnInfo[]; + description?: string | null; +} + +export interface ColumnInfo { + column_name: string; + data_type: string; + is_nullable?: string; + column_default?: string | null; +} + +export interface BatchMappingRequest { + from_connection_type: 'internal' | 'external' | 'restapi'; + from_connection_id?: number; + from_table_name: string; + from_column_name: string; + from_column_type?: string; + from_api_url?: string; + from_api_key?: string; + from_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + to_connection_type: 'internal' | 'external' | 'restapi'; + to_connection_id?: number; + to_table_name: string; + to_column_name: string; + to_column_type?: string; + to_api_url?: string; + to_api_key?: string; + to_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + to_api_body?: string; // Request Body 템플릿 (DB → REST API 배치용) + mapping_order?: number; +} + +export interface CreateBatchConfigRequest { + batchName: string; + description?: string; + cronSchedule: string; + mappings: BatchMappingRequest[]; +} + +export interface UpdateBatchConfigRequest { + batchName?: string; + description?: string; + cronSchedule?: string; + mappings?: BatchMappingRequest[]; + isActive?: string; +} + +export interface BatchValidationResult { + isValid: boolean; + errors: string[]; + warnings?: string[]; +} + +export interface ApiResponse { + success: boolean; + data?: T; + message?: string; + error?: string; + pagination?: { + page: number; + limit: number; + total: number; + totalPages: number; + }; +} diff --git a/backend-node/src/types/externalCallTypes.ts b/backend-node/src/types/externalCallTypes.ts index 47f52411..4e8edd4c 100644 --- a/backend-node/src/types/externalCallTypes.ts +++ b/backend-node/src/types/externalCallTypes.ts @@ -53,14 +53,26 @@ export interface DiscordSettings extends ExternalCallConfig { avatarUrl?: string; } +// 인증 설정 타입 +export interface AuthenticationSettings { + type: "none" | "api-key" | "basic" | "bearer" | "custom"; + apiKey?: string; + username?: string; + password?: string; + token?: string; + headerName?: string; + headerValue?: string; +} + // 일반 REST API 설정 export interface GenericApiSettings extends ExternalCallConfig { callType: "rest-api"; apiType: "generic"; url: string; - method: "GET" | "POST" | "PUT" | "DELETE"; + method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD"; headers?: Record; body?: string; + authentication?: AuthenticationSettings; } // 이메일 설정 diff --git a/backend-node/src/types/oracledb.d.ts b/backend-node/src/types/oracledb.d.ts new file mode 100644 index 00000000..818b6a6f --- /dev/null +++ b/backend-node/src/types/oracledb.d.ts @@ -0,0 +1,18 @@ +declare module 'oracledb' { + export interface Connection { + execute(sql: string, bindParams?: any, options?: any): Promise; + close(): Promise; + } + + export interface ConnectionConfig { + user: string; + password: string; + connectString: string; + } + + export function getConnection(config: ConnectionConfig): Promise; + export function createPool(config: any): Promise; + export function getPool(): any; + export function close(): Promise; +} + diff --git a/backend-node/tsconfig.json b/backend-node/tsconfig.json index 95e2dd18..848784d8 100644 --- a/backend-node/tsconfig.json +++ b/backend-node/tsconfig.json @@ -33,6 +33,6 @@ "@/validators/*": ["src/validators/*"] } }, - "include": ["src/**/*"], + "include": ["src/**/*", "src/types/**/*.d.ts"], "exclude": ["node_modules", "dist", "**/*.test.ts"] } diff --git a/docker/dev/frontend.Dockerfile b/docker/dev/frontend.Dockerfile index 390e8324..fdad92f6 100644 --- a/docker/dev/frontend.Dockerfile +++ b/docker/dev/frontend.Dockerfile @@ -16,5 +16,5 @@ COPY . . # 포트 노출 EXPOSE 3000 -# 개발 서버 시작 -CMD ["npm", "run", "dev"] \ No newline at end of file +# 개발 서버 시작 (Docker에서는 포트 3000 사용) +CMD ["npm", "run", "dev", "--", "-p", "3000"] \ No newline at end of file diff --git a/docs/batch.html b/docs/batch.html new file mode 100644 index 00000000..bc70b3cd --- /dev/null +++ b/docs/batch.html @@ -0,0 +1,585 @@ + + + + + + 배치관리 매핑 시스템 + + + +
+
+ 배치관리 매핑 시스템 +
+ +
+
+ + +
+
+ + +
+
+ +
+
+
FROM (원본 데이터베이스)
+
+
+ 1단계: 컨넥션을 선택하세요 → 2단계: 테이블을 선택하세요 → 3단계: 컬럼을 클릭해서 매핑하세요 +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+ +
+
TO (대상 데이터베이스)
+
+
+ FROM에서 컬럼을 선택한 후, 여기서 대상 컬럼을 클릭하면 매핑됩니다 +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+
+ + + + +
+ + + + \ No newline at end of file diff --git a/frontend/app/(main)/admin/batch-management-new/page.tsx b/frontend/app/(main)/admin/batch-management-new/page.tsx new file mode 100644 index 00000000..53a840ff --- /dev/null +++ b/frontend/app/(main)/admin/batch-management-new/page.tsx @@ -0,0 +1,1228 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Trash2, Plus, ArrowRight, Save, RefreshCw, Globe, Database, Eye } from "lucide-react"; +import { toast } from "sonner"; +import { BatchManagementAPI } from "@/lib/api/batchManagement"; + +// 타입 정의 +type BatchType = 'db-to-restapi' | 'restapi-to-db' | 'restapi-to-restapi'; + +interface BatchTypeOption { + value: BatchType; + label: string; + description: string; +} + +interface BatchConnectionInfo { + id: number; + name: string; + type: string; +} + +interface BatchColumnInfo { + column_name: string; + data_type: string; + is_nullable: string; +} + +export default function BatchManagementNewPage() { + const router = useRouter(); + + // 기본 상태 + const [batchName, setBatchName] = useState(""); + const [cronSchedule, setCronSchedule] = useState("0 12 * * *"); + const [description, setDescription] = useState(""); + + // 연결 정보 + const [connections, setConnections] = useState([]); + const [toConnection, setToConnection] = useState(null); + const [toTables, setToTables] = useState([]); + const [toTable, setToTable] = useState(""); + const [toColumns, setToColumns] = useState([]); + + // REST API 설정 (REST API → DB용) + const [fromApiUrl, setFromApiUrl] = useState(""); + const [fromApiKey, setFromApiKey] = useState(""); + const [fromEndpoint, setFromEndpoint] = useState(""); + const [fromApiMethod, setFromApiMethod] = useState<'GET'>('GET'); // GET만 지원 + + // DB → REST API용 상태 + const [fromConnection, setFromConnection] = useState(null); + const [fromTables, setFromTables] = useState([]); + const [fromTable, setFromTable] = useState(""); + const [fromColumns, setFromColumns] = useState([]); + const [selectedColumns, setSelectedColumns] = useState([]); // 선택된 컬럼들 + const [dbToApiFieldMapping, setDbToApiFieldMapping] = useState>({}); // DB 컬럼 → API 필드 매핑 + + // REST API 대상 설정 (DB → REST API용) + const [toApiUrl, setToApiUrl] = useState(""); + const [toApiKey, setToApiKey] = useState(""); + const [toEndpoint, setToEndpoint] = useState(""); + const [toApiMethod, setToApiMethod] = useState<'POST' | 'PUT' | 'DELETE'>('POST'); + const [toApiBody, setToApiBody] = useState(''); // Request Body 템플릿 + const [toApiFields, setToApiFields] = useState([]); // TO API 필드 목록 + const [urlPathColumn, setUrlPathColumn] = useState(""); // URL 경로에 사용할 컬럼 (PUT/DELETE용) + + // API 데이터 미리보기 + const [fromApiData, setFromApiData] = useState([]); + const [fromApiFields, setFromApiFields] = useState([]); + + // API 필드 → DB 컬럼 매핑 + const [apiFieldMappings, setApiFieldMappings] = useState>({}); + + // 배치 타입 상태 + const [batchType, setBatchType] = useState('restapi-to-db'); + + // 배치 타입 옵션 + const batchTypeOptions: BatchTypeOption[] = [ + { + value: 'restapi-to-db', + label: 'REST API → DB', + description: 'REST API에서 데이터베이스로 데이터 수집' + }, + { + value: 'db-to-restapi', + label: 'DB → REST API', + description: '데이터베이스에서 REST API로 데이터 전송' + } + ]; + + // 초기 데이터 로드 + useEffect(() => { + loadConnections(); + }, []); + + // 배치 타입 변경 시 상태 초기화 + useEffect(() => { + // 공통 초기화 + setApiFieldMappings({}); + + // REST API → DB 관련 초기화 + setToConnection(null); + setToTables([]); + setToTable(""); + setToColumns([]); + setFromApiUrl(""); + setFromApiKey(""); + setFromEndpoint(""); + setFromApiData([]); + setFromApiFields([]); + + // DB → REST API 관련 초기화 + setFromConnection(null); + setFromTables([]); + setFromTable(""); + setFromColumns([]); + setSelectedColumns([]); + setDbToApiFieldMapping({}); + setToApiUrl(""); + setToApiKey(""); + setToEndpoint(""); + setToApiBody(""); + setToApiFields([]); + }, [batchType]); + + + // 연결 목록 로드 + const loadConnections = async () => { + try { + const result = await BatchManagementAPI.getAvailableConnections(); + setConnections(result || []); + } catch (error) { + console.error("연결 목록 로드 오류:", error); + toast.error("연결 목록을 불러오는데 실패했습니다."); + } + }; + + // TO 연결 변경 핸들러 + const handleToConnectionChange = async (connectionValue: string) => { + let connection: BatchConnectionInfo | null = null; + + if (connectionValue === 'internal') { + // 내부 데이터베이스 선택 + connection = connections.find(conn => conn.type === 'internal') || null; + } else { + // 외부 데이터베이스 선택 + const connectionId = parseInt(connectionValue); + connection = connections.find(conn => conn.id === connectionId) || null; + } + + setToConnection(connection); + setToTable(""); + setToColumns([]); + + if (connection) { + try { + const connectionType = connection.type === 'internal' ? 'internal' : 'external'; + const result = await BatchManagementAPI.getTablesFromConnection(connectionType, connection.id); + const tableNames = Array.isArray(result) + ? result.map((table: any) => typeof table === 'string' ? table : table.table_name || String(table)) + : []; + setToTables(tableNames); + } catch (error) { + console.error("테이블 목록 로드 오류:", error); + toast.error("테이블 목록을 불러오는데 실패했습니다."); + } + } + }; + + // TO 테이블 변경 핸들러 + const handleToTableChange = async (tableName: string) => { + console.log("🔍 테이블 변경:", { tableName, toConnection }); + setToTable(tableName); + setToColumns([]); + + if (toConnection && tableName) { + try { + const connectionType = toConnection.type === 'internal' ? 'internal' : 'external'; + console.log("🔍 컬럼 조회 시작:", { connectionType, connectionId: toConnection.id, tableName }); + + const result = await BatchManagementAPI.getTableColumns(connectionType, tableName, toConnection.id); + console.log("🔍 컬럼 조회 결과:", result); + + if (result && result.length > 0) { + setToColumns(result); + console.log("✅ 컬럼 설정 완료:", result.length, "개"); + } else { + setToColumns([]); + console.log("⚠️ 컬럼이 없음"); + } + } catch (error) { + console.error("❌ 컬럼 목록 로드 오류:", error); + toast.error("컬럼 목록을 불러오는데 실패했습니다."); + setToColumns([]); + } + } + }; + + // FROM 연결 변경 핸들러 (DB → REST API용) + const handleFromConnectionChange = async (connectionValue: string) => { + let connection: BatchConnectionInfo | null = null; + if (connectionValue === 'internal') { + connection = connections.find(conn => conn.type === 'internal') || null; + } else { + const connectionId = parseInt(connectionValue); + connection = connections.find(conn => conn.id === connectionId) || null; + } + setFromConnection(connection); + setFromTable(""); + setFromColumns([]); + + if (connection) { + try { + const connectionType = connection.type === 'internal' ? 'internal' : 'external'; + const result = await BatchManagementAPI.getTablesFromConnection(connectionType, connection.id); + const tableNames = Array.isArray(result) + ? result.map((table: any) => typeof table === 'string' ? table : table.table_name || String(table)) + : []; + setFromTables(tableNames); + } catch (error) { + console.error("테이블 목록 로드 오류:", error); + toast.error("테이블 목록을 불러오는데 실패했습니다."); + } + } + }; + + // FROM 테이블 변경 핸들러 (DB → REST API용) + const handleFromTableChange = async (tableName: string) => { + console.log("🔍 FROM 테이블 변경:", { tableName, fromConnection }); + setFromTable(tableName); + setFromColumns([]); + setSelectedColumns([]); // 선택된 컬럼도 초기화 + setDbToApiFieldMapping({}); // 매핑도 초기화 + + if (fromConnection && tableName) { + try { + const connectionType = fromConnection.type === 'internal' ? 'internal' : 'external'; + console.log("🔍 FROM 컬럼 조회 시작:", { connectionType, connectionId: fromConnection.id, tableName }); + + const result = await BatchManagementAPI.getTableColumns(connectionType, tableName, fromConnection.id); + console.log("🔍 FROM 컬럼 조회 결과:", result); + + if (result && result.length > 0) { + setFromColumns(result); + console.log("✅ FROM 컬럼 설정 완료:", result.length, "개"); + } else { + setFromColumns([]); + console.log("⚠️ FROM 컬럼이 없음"); + } + } catch (error) { + console.error("❌ FROM 컬럼 목록 로드 오류:", error); + toast.error("컬럼 목록을 불러오는데 실패했습니다."); + setFromColumns([]); + } + } + }; + + // TO API 미리보기 (DB → REST API용) + const previewToApiData = async () => { + if (!toApiUrl || !toApiKey || !toEndpoint) { + toast.error("API URL, API Key, 엔드포인트를 모두 입력해주세요."); + return; + } + + try { + console.log("🔍 TO API 미리보기 시작:", { toApiUrl, toApiKey, toEndpoint, toApiMethod }); + + const result = await BatchManagementAPI.previewRestApiData( + toApiUrl, + toApiKey, + toEndpoint, + 'GET' // 미리보기는 항상 GET으로 + ); + + console.log("🔍 TO API 미리보기 결과:", result); + + if (result.fields && result.fields.length > 0) { + setToApiFields(result.fields); + toast.success(`TO API 필드 ${result.fields.length}개를 조회했습니다.`); + } else { + setToApiFields([]); + toast.warning("TO API에서 필드를 찾을 수 없습니다."); + } + } catch (error) { + console.error("❌ TO API 미리보기 오류:", error); + toast.error("TO API 미리보기에 실패했습니다."); + setToApiFields([]); + } + }; + + // REST API 데이터 미리보기 + const previewRestApiData = async () => { + if (!fromApiUrl || !fromApiKey || !fromEndpoint) { + toast.error("API URL, API Key, 엔드포인트를 모두 입력해주세요."); + return; + } + + try { + console.log("REST API 데이터 미리보기 시작..."); + + const result = await BatchManagementAPI.previewRestApiData( + fromApiUrl, + fromApiKey, + fromEndpoint, + fromApiMethod + ); + + console.log("API 미리보기 결과:", result); + console.log("result.fields:", result.fields); + console.log("result.samples:", result.samples); + console.log("result.totalCount:", result.totalCount); + + if (result.fields && result.fields.length > 0) { + console.log("✅ 백엔드에서 fields 제공됨:", result.fields); + setFromApiFields(result.fields); + setFromApiData(result.samples); + + console.log("추출된 필드:", result.fields); + toast.success(`API 데이터 미리보기 완료! ${result.fields.length}개 필드, ${result.totalCount}개 레코드`); + } else if (result.samples && result.samples.length > 0) { + // 백엔드에서 fields를 제대로 보내지 않은 경우, 프론트엔드에서 직접 추출 + console.log("⚠️ 백엔드에서 fields가 없어서 프론트엔드에서 추출"); + const extractedFields = Object.keys(result.samples[0]); + console.log("프론트엔드에서 추출한 필드:", extractedFields); + + setFromApiFields(extractedFields); + setFromApiData(result.samples); + + toast.success(`API 데이터 미리보기 완료! ${extractedFields.length}개 필드, ${result.samples.length}개 레코드`); + } else { + console.log("❌ 데이터가 없음"); + setFromApiFields([]); + setFromApiData([]); + toast.warning("API에서 데이터를 가져올 수 없습니다."); + } + } catch (error) { + console.error("REST API 미리보기 오류:", error); + toast.error("API 데이터 미리보기에 실패했습니다."); + setFromApiFields([]); + setFromApiData([]); + } + }; + + // 배치 설정 저장 + const handleSave = async () => { + if (!batchName.trim()) { + toast.error("배치명을 입력해주세요."); + return; + } + + // 배치 타입별 검증 및 저장 + if (batchType === 'restapi-to-db') { + const mappedFields = Object.keys(apiFieldMappings).filter(field => apiFieldMappings[field]); + if (mappedFields.length === 0) { + toast.error("최소 하나의 API 필드를 DB 컬럼에 매핑해주세요."); + return; + } + + // API 필드 매핑을 배치 매핑 형태로 변환 + const apiMappings = mappedFields.map(apiField => ({ + from_connection_type: 'restapi' as const, + from_table_name: fromEndpoint, // API 엔드포인트 + from_column_name: apiField, // API 필드명 + from_api_url: fromApiUrl, + from_api_key: fromApiKey, + from_api_method: fromApiMethod, + to_connection_type: toConnection?.type === 'internal' ? 'internal' : 'external', + to_connection_id: toConnection?.type === 'internal' ? undefined : toConnection?.id, + to_table_name: toTable, + to_column_name: apiFieldMappings[apiField], // 매핑된 DB 컬럼 + mapping_type: 'direct' as const + })); + + console.log("REST API 배치 설정 저장:", { + batchName, + batchType, + cronSchedule, + description, + apiMappings + }); + + // 실제 API 호출 + try { + const result = await BatchManagementAPI.saveRestApiBatch({ + batchName, + batchType, + cronSchedule, + description, + apiMappings + }); + + if (result.success) { + toast.success(result.message || "REST API 배치 설정이 저장되었습니다."); + setTimeout(() => { + router.push('/admin/batchmng'); + }, 1000); + } else { + toast.error(result.message || "배치 저장에 실패했습니다."); + } + } catch (error) { + console.error("배치 저장 오류:", error); + toast.error("배치 저장 중 오류가 발생했습니다."); + } + return; + } else if (batchType === 'db-to-restapi') { + // DB → REST API 배치 검증 + if (!fromConnection || !fromTable || selectedColumns.length === 0) { + toast.error("소스 데이터베이스, 테이블, 컬럼을 선택해주세요."); + return; + } + + if (!toApiUrl || !toApiKey || !toEndpoint) { + toast.error("대상 API URL, API Key, 엔드포인트를 입력해주세요."); + return; + } + + if ((toApiMethod === 'POST' || toApiMethod === 'PUT') && !toApiBody) { + toast.error("POST/PUT 메서드의 경우 Request Body 템플릿을 입력해주세요."); + return; + } + + // DELETE의 경우 빈 Request Body라도 템플릿 로직을 위해 "{}" 설정 + let finalToApiBody = toApiBody; + if (toApiMethod === 'DELETE' && !finalToApiBody.trim()) { + finalToApiBody = '{}'; + } + + // DB → REST API 매핑 생성 (선택된 컬럼만) + const selectedColumnObjects = fromColumns.filter(column => selectedColumns.includes(column.column_name)); + const dbMappings = selectedColumnObjects.map((column, index) => ({ + from_connection_type: fromConnection.type === 'internal' ? 'internal' : 'external', + from_connection_id: fromConnection.type === 'internal' ? undefined : fromConnection.id, + from_table_name: fromTable, + from_column_name: column.column_name, + from_column_type: column.data_type, + to_connection_type: 'restapi' as const, + to_table_name: toEndpoint, // API 엔드포인트 + to_column_name: dbToApiFieldMapping[column.column_name] || column.column_name, // 매핑된 API 필드명 + to_api_url: toApiUrl, + to_api_key: toApiKey, + to_api_method: toApiMethod, + to_api_body: finalToApiBody, // Request Body 템플릿 + mapping_type: 'template' as const, + mapping_order: index + 1 + })); + + // URL 경로 파라미터 매핑 추가 (PUT/DELETE용) + if ((toApiMethod === 'PUT' || toApiMethod === 'DELETE') && urlPathColumn) { + const urlPathColumnObject = fromColumns.find(col => col.column_name === urlPathColumn); + if (urlPathColumnObject) { + dbMappings.push({ + from_connection_type: fromConnection.type === 'internal' ? 'internal' : 'external', + from_connection_id: fromConnection.type === 'internal' ? undefined : fromConnection.id, + from_table_name: fromTable, + from_column_name: urlPathColumn, + from_column_type: urlPathColumnObject.data_type, + to_connection_type: 'restapi' as const, + to_table_name: toEndpoint, + to_column_name: 'URL_PATH_PARAM', // 특별한 식별자 + to_api_url: toApiUrl, + to_api_key: toApiKey, + to_api_method: toApiMethod, + to_api_body: finalToApiBody, + mapping_type: 'url_path' as const, + mapping_order: 999 // 마지막 순서 + }); + } + } + + console.log("DB → REST API 배치 설정 저장:", { + batchName, + batchType, + cronSchedule, + description, + dbMappings + }); + + // 실제 API 호출 (기존 saveRestApiBatch 재사용) + try { + const result = await BatchManagementAPI.saveRestApiBatch({ + batchName, + batchType, + cronSchedule, + description, + apiMappings: dbMappings + }); + + if (result.success) { + toast.success(result.message || "DB → REST API 배치 설정이 저장되었습니다."); + setTimeout(() => { + router.push('/admin/batchmng'); + }, 1000); + } else { + toast.error(result.message || "배치 저장에 실패했습니다."); + } + } catch (error) { + console.error("배치 저장 오류:", error); + toast.error("배치 저장 중 오류가 발생했습니다."); + } + return; + } + + toast.error("지원하지 않는 배치 타입입니다."); + }; + + return ( +
+
+

고급 배치 생성

+
+ + +
+
+ + {/* 기본 정보 */} + + + 기본 정보 + + + {/* 배치 타입 선택 */} +
+ +
+ {batchTypeOptions.map((option) => ( +
setBatchType(option.value)} + > +
+ {option.value === 'restapi-to-db' ? ( + + ) : ( + + )} +
+
{option.label}
+
{option.description}
+
+
+
+ ))} +
+
+ +
+
+ + setBatchName(e.target.value)} + placeholder="배치명을 입력하세요" + /> +
+
+ + setCronSchedule(e.target.value)} + placeholder="0 12 * * *" + /> +
+
+ +
+ +