diff --git a/.cursor/rules/admin-page-style-guide.mdc b/.cursor/rules/admin-page-style-guide.mdc new file mode 100644 index 00000000..d0cdaf23 --- /dev/null +++ b/.cursor/rules/admin-page-style-guide.mdc @@ -0,0 +1,749 @@ +--- +description: 관리자 페이지 표준 스타일 가이드 - shadcn/ui 기반 일관된 디자인 시스템 +globs: **/app/(main)/admin/**/*.tsx,**/components/admin/**/*.tsx +--- + +# 관리자 페이지 표준 스타일 가이드 + +이 가이드는 관리자 페이지의 일관된 UI/UX를 위한 표준 스타일 규칙입니다. +모든 관리자 페이지는 이 가이드를 따라야 합니다. + +## 1. 페이지 레이아웃 구조 + +### 기본 페이지 템플릿 + +```tsx +export default function AdminPage() { + return ( +
+
+ {/* 페이지 헤더 */} +
+

페이지 제목

+

페이지 설명

+
+ + {/* 메인 컨텐츠 */} + +
+ + {/* Scroll to Top 버튼 (모바일/태블릿 전용) */} + +
+ ); +} +``` + +**필수 적용 사항:** + +- 최상위: `flex min-h-screen flex-col bg-background` +- 컨텐츠 영역: `space-y-6 p-6` (24px 좌우 여백, 24px 간격) +- 헤더 구분선: `border-b pb-4` (테두리 박스 사용 금지) +- Scroll to Top: 모든 관리자 페이지에 포함 + +## 2. Color System (색상 시스템) + +### CSS Variables 사용 (하드코딩 금지) + +```tsx +// ❌ 잘못된 예시 +
+ +// ✅ 올바른 예시 +
+
+
+``` + +**표준 색상 토큰:** + +- `bg-background` / `text-foreground`: 기본 배경/텍스트 +- `bg-card` / `text-card-foreground`: 카드 배경/텍스트 +- `bg-muted` / `text-muted-foreground`: 보조 배경/텍스트 +- `bg-primary` / `text-primary`: 메인 액션 +- `bg-destructive` / `text-destructive`: 삭제/에러 +- `border-border`: 테두리 +- `ring-ring`: 포커스 링 + +## 3. Typography (타이포그래피) + +### 표준 텍스트 크기와 가중치 + +```tsx +// 페이지 제목 +

+ +// 섹션 제목 +

+

+

+ +// 본문 텍스트 +

+ +// 보조 텍스트 +

+

+ +// 라벨 +

// 24px + +// 섹션 레벨 간격 +
// 16px + +// 필드 레벨 간격 +
// 8px + +// 패딩 +
// 24px (카드) +
// 16px (내부 섹션) + +// 갭 +
// 16px (flex/grid) +
// 8px (버튼 그룹) +``` + +## 5. 검색 툴바 (Toolbar) + +### 패턴 A: 통합 검색 영역 (권장) + +```tsx +
+ {/* 검색 및 액션 영역 */} +
+ {/* 검색 영역 */} +
+ {/* 통합 검색 */} +
+
+ + +
+
+ + {/* 고급 검색 토글 */} + +
+ + {/* 액션 버튼 영역 */} +
+
+ 총{" "} + + {count.toLocaleString()} + {" "} + 건 +
+ +
+
+ + {/* 고급 검색 옵션 */} + {showAdvanced && ( +
+
+

고급 검색 옵션

+

설명

+
+
+ +
+
+ )} +
+``` + +### 패턴 B: 제목 + 검색 + 버튼 한 줄 (공간 효율적) + +```tsx +{ + /* 상단 헤더: 제목 + 검색 + 버튼 */ +} +
+ {/* 왼쪽: 제목 */} +

페이지 제목

+ + {/* 오른쪽: 검색 + 버튼 */} +
+ {/* 필터 선택 */} +
+ +
+ + {/* 검색 입력 */} +
+ +
+ + {/* 초기화 버튼 */} + + + {/* 주요 액션 버튼 */} + + + {/* 조건부 버튼 (선택 시) */} + {selectedCount > 0 && ( + + )} +
+
; +``` + +**필수 적용 사항:** + +- ❌ 검색 영역에 박스/테두리 사용 금지 +- ✅ 검색창 권장 너비: `w-full sm:w-[240px]` ~ `sm:w-[400px]` +- ✅ 필터/Select 권장 너비: `w-full sm:w-[160px]` ~ `sm:w-[200px]` +- ✅ 고급 검색 필드: placeholder만 사용 (라벨 제거) +- ✅ 검색 아이콘: `Search` (lucide-react) +- ✅ Input/Select 높이: `h-10` (40px) +- ✅ 상단 헤더에 `relative` 추가 (드롭다운 표시용) + +## 6. Button (버튼) + +### 표준 버튼 variants와 크기 + +```tsx +// Primary 액션 + + +// Secondary 액션 + + +// Ghost 버튼 (아이콘 전용) + + +// Destructive + +``` + +**표준 크기:** + +- `h-10`: 기본 버튼 (40px) +- `h-9`: 작은 버튼 (36px) +- `h-8`: 아이콘 버튼 (32px) + +**아이콘 크기:** + +- `h-4 w-4`: 버튼 내 아이콘 (16px) + +## 7. Input (입력 필드) + +### 표준 Input 스타일 + +```tsx +// 기본 + + +// 검색 (아이콘 포함) +
+ + +
+ +// 로딩/액티브 + + +// 비활성화 + +``` + +**필수 적용 사항:** + +- 높이: `h-10` (40px) +- 텍스트: `text-sm` +- 포커스: 자동 적용 (`ring-2 ring-ring`) + +## 8. Table & Card (테이블과 카드) + +### 반응형 테이블/카드 구조 + +```tsx +// 실제 데이터 렌더링 +return ( + <> + {/* 데스크톱 테이블 뷰 (lg 이상) */} +
+ + + + 컬럼 + + + + + 데이터 + + +
+
+ + {/* 모바일/태블릿 카드 뷰 (lg 미만) */} +
+ {items.map((item) => ( +
+ {/* 헤더 */} +
+
+

{item.name}

+

{item.id}

+
+ +
+ + {/* 정보 */} +
+
+ 필드 + {item.value} +
+
+ + {/* 액션 */} +
+ +
+
+ ))} +
+ +); +``` + +**테이블 표준:** + +- 헤더: `h-12` (48px), `bg-muted/50`, `font-semibold` +- 데이터 행: `h-16` (64px), `hover:bg-muted/50` +- 텍스트: `text-sm` + +**카드 표준:** + +- 컨테이너: `rounded-lg border bg-card p-4 shadow-sm` +- 헤더 제목: `text-base font-semibold` +- 부제목: `text-sm text-muted-foreground` +- 정보 라벨: `text-sm text-muted-foreground` +- 정보 값: `text-sm font-medium` +- 버튼: `h-9 flex-1 gap-2 text-sm` + +## 9. Loading States (로딩 상태) + +### Skeleton UI 패턴 + +```tsx +// 테이블 스켈레톤 (데스크톱) +
+ + ... + + {Array.from({ length: 10 }).map((_, index) => ( + + +
+
+
+ ))} +
+
+
+ +// 카드 스켈레톤 (모바일/태블릿) +
+ {Array.from({ length: 6 }).map((_, index) => ( +
+
+
+
+
+
+
+
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+
+
+ ))} +
+
+ ))} +
+``` + +## 10. Empty States (빈 상태) + +### 표준 Empty State + +```tsx +
+
+

데이터가 없습니다.

+
+
+``` + +## 11. Error States (에러 상태) + +### 표준 에러 메시지 + +```tsx +
+
+

+ 오류가 발생했습니다 +

+ +
+

{errorMessage}

+
+``` + +## 12. Responsive Design (반응형) + +### Breakpoints + +- `sm`: 640px (모바일 가로/태블릿) +- `md`: 768px (태블릿) +- `lg`: 1024px (노트북) +- `xl`: 1280px (데스크톱) + +### 모바일 우선 패턴 + +```tsx +// 레이아웃 +
+ +// 그리드 +
+ +// 검색창 +
+ +// 테이블/카드 전환 +
{/* 데스크톱 테이블 */} +
{/* 모바일 카드 */} + +// 간격 +
+
+``` + +## 13. 좌우 레이아웃 (Side-by-Side Layout) + +### 사이드바 + 메인 영역 구조 + +```tsx +
+ {/* 좌측 사이드바 (20-30%) */} +
+
+

사이드바 제목

+ + {/* 사이드바 컨텐츠 */} +
+
+

항목

+

설명

+
+
+
+
+ + {/* 우측 메인 영역 (70-80%) */} +
+
+

메인 제목

+ + {/* 메인 컨텐츠 */} +
{/* 컨텐츠 */}
+
+
+
+``` + +**필수 적용 사항:** + +- ✅ 좌우 구분: `border-r` 사용 (세로 구분선) +- ✅ 간격: `gap-6` (24px) +- ✅ 사이드바 패딩: `pr-6` (오른쪽 24px) +- ✅ 메인 영역 패딩: `pl-0` (gap으로 간격 확보) +- ✅ 비율: 20:80 또는 30:70 +- ❌ 과도한 구분선 사용 금지 (세로 구분선 1개만) +- ❌ 사이드바와 메인 영역 각각에 추가 border 금지 + +## 14. Custom Dropdown (커스텀 드롭다운) + +### 커스텀 Select/Dropdown 구조 + +```tsx +{ + /* 드롭다운 컨테이너 */ +} +
+
+ {/* 트리거 버튼 */} + + + {/* 드롭다운 메뉴 */} + {isOpen && ( +
+ {/* 검색 (선택사항) */} +
+ setSearchText(e.target.value)} + className="h-8 text-sm" + onClick={(e) => e.stopPropagation()} + /> +
+ + {/* 옵션 목록 */} +
+ {options.map((option) => ( +
{ + setValue(option.value); + setIsOpen(false); + }} + > + {option.label} +
+ ))} +
+
+ )} +
+
; +``` + +**필수 적용 사항:** + +- ✅ z-index: `z-[100]` (다른 요소 위에 표시) +- ✅ 그림자: `shadow-lg` (명확한 레이어 구분) +- ✅ 최소 너비: `min-w-[200px]` (내용이 잘리지 않도록) +- ✅ 최대 높이: `max-h-48` (스크롤 가능) +- ✅ 애니메이션: 화살표 아이콘 회전 (`rotate-180`) +- ✅ 부모 요소: `relative` 클래스 필요 +- ⚠️ 부모에 `overflow-hidden` 사용 시 드롭다운 잘림 주의 + +**드롭다운이 잘릴 때 해결방법:** + +```tsx +// 부모 요소의 overflow 제거 +
// overflow-hidden 제거 + +// 또는 상단 헤더에 relative 추가 +
// 드롭다운 포지셔닝 기준점 +``` + +## 15. Scroll to Top Button + +### 모바일/태블릿 전용 버튼 + +```tsx +import { ScrollToTop } from "@/components/common/ScrollToTop"; + +// 페이지에 추가 +; +``` + +**특징:** + +- 데스크톱에서 숨김 (`lg:hidden`) +- 스크롤 200px 이상 시 나타남 +- 부드러운 페이드 인/아웃 애니메이션 +- 오른쪽 하단 고정 위치 +- 원형 디자인 (`rounded-full`) + +## 14. Accessibility (접근성) + +### 필수 적용 사항 + +```tsx +// Label과 Input 연결 + + + +// 버튼에 aria-label + + +// Switch에 aria-label + + +// 포커스 표시 (자동 적용) +focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring +``` + +## 15. Class 순서 (일관성) + +### 표준 클래스 작성 순서 + +1. Layout: `flex`, `grid`, `block` +2. Position: `fixed`, `absolute`, `relative` +3. Sizing: `w-full`, `h-10` +4. Spacing: `p-4`, `m-2`, `gap-4` +5. Typography: `text-sm`, `font-medium` +6. Colors: `bg-primary`, `text-white` +7. Border: `border`, `rounded-md` +8. Effects: `shadow-sm`, `opacity-50` +9. States: `hover:`, `focus:`, `disabled:` +10. Responsive: `sm:`, `md:`, `lg:` + +## 16. 금지 사항 + +### ❌ 절대 사용하지 말 것 + +1. 하드코딩된 색상 (`bg-gray-50`, `text-blue-500` 등) +2. 인라인 스타일로 색상 지정 (`style={{ color: '#3b82f6' }}`) +3. 포커스 스타일 제거 (`outline-none`만 단독 사용) +4. 중첩된 박스 (Card 안에 Card, Border 안에 Border) +5. 검색 영역에 불필요한 박스/테두리 +6. 검색 필드에 라벨 (placeholder만 사용) +7. 반응형 무시 (데스크톱 전용 스타일) +8. **이모지 사용** (사용자가 명시적으로 요청하지 않는 한 절대 사용 금지) +9. 과도한 구분선 사용 (최소한으로 유지) +10. 드롭다운 부모에 `overflow-hidden` (잘림 발생) + +## 17. 체크리스트 + +새로운 관리자 페이지 작성 시 다음을 확인하세요: + +### 페이지 레벨 + +- [ ] `bg-background` 사용 (하드코딩 금지) +- [ ] `space-y-6 p-6` 구조 +- [ ] 페이지 헤더에 `border-b pb-4` +- [ ] `ScrollToTop` 컴포넌트 포함 + +### 검색 툴바 + +- [ ] 박스/테두리 없음 +- [ ] 검색창 최대 너비 `sm:w-[400px]` +- [ ] 고급 검색 필드에 라벨 없음 (placeholder만) +- [ ] 반응형 레이아웃 적용 + +### 테이블/카드 + +- [ ] 데스크톱: 테이블 (`hidden lg:block`) +- [ ] 모바일: 카드 (`lg:hidden`) +- [ ] 표준 높이와 간격 적용 +- [ ] 로딩/Empty 상태 구현 + +### 버튼 + +- [ ] 표준 variants 사용 +- [ ] 표준 높이: `h-10`, `h-9`, `h-8` +- [ ] 아이콘 크기: `h-4 w-4` +- [ ] `gap-2`로 아이콘과 텍스트 간격 + +### 반응형 + +- [ ] 모바일 우선 디자인 +- [ ] Breakpoints 적용 (`sm:`, `lg:`) +- [ ] 테이블/카드 전환 +- [ ] Scroll to Top 버튼 + +### 접근성 + +- [ ] Label `htmlFor` / Input `id` 연결 +- [ ] 버튼 `aria-label` +- [ ] Switch `aria-label` +- [ ] 포커스 표시 유지 + +## 참고 파일 + +완성된 예시: + +### 기본 패턴 + +- [사용자 관리 페이지]() - 기본 페이지 구조 +- [검색 툴바](mdc:frontend/components/admin/UserToolbar.tsx) - 패턴 A (통합 검색) +- [테이블/카드](mdc:frontend/components/admin/UserTable.tsx) - 반응형 테이블/카드 +- [Scroll to Top](mdc:frontend/components/common/ScrollToTop.tsx) - 스크롤 버튼 + +### 고급 패턴 + +- [메뉴 관리 페이지]() - 좌우 레이아웃 + 패턴 B (제목+검색+버튼) +- [메뉴 관리 컴포넌트](mdc:frontend/components/admin/MenuManagement.tsx) - 커스텀 드롭다운 + 좌우 레이아웃 diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 00000000..e2fa0458 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,857 @@ +# Cursor Rules for ERP-node Project + +## shadcn/ui 웹 스타일 가이드라인 + +모든 프론트엔드 개발 시 다음 shadcn/ui 기반 스타일 가이드라인을 준수해야 합니다. + +### 1. Color System (색상 시스템) + +#### CSS Variables 사용 +shadcn은 CSS Variables를 사용하여 테마를 관리하며, 모든 색상은 HSL 형식으로 정의됩니다. + +**기본 색상 토큰 (항상 사용):** +- `--background`: 페이지 배경 +- `--foreground`: 기본 텍스트 +- `--primary`: 메인 액션 (Indigo 계열) +- `--primary-foreground`: Primary 위 텍스트 +- `--secondary`: 보조 액션 +- `--muted`: 약한 배경 +- `--muted-foreground`: 보조 텍스트 +- `--destructive`: 삭제/에러 (Rose 계열) +- `--border`: 테두리 +- `--ring`: 포커스 링 + +**Tailwind 클래스로 사용:** +```tsx +bg-primary text-primary-foreground +bg-secondary text-secondary-foreground +bg-muted text-muted-foreground +bg-destructive text-destructive-foreground +border-border +``` + +**추가 시맨틱 컬러:** +- Success: `--success` (Emerald-600 계열) +- Warning: `--warning` (Amber-500 계열) +- Info: `--info` (Cyan-500 계열) + +### 2. Spacing System (간격) + +**Tailwind Scale (4px 기준):** +- 0.5 = 2px, 1 = 4px, 2 = 8px, 3 = 12px, 4 = 16px, 6 = 24px, 8 = 32px + +**컴포넌트별 권장 간격:** +- 카드 패딩: `p-6` (24px) +- 카드 간 마진: `gap-6` (24px) +- 폼 필드 간격: `space-y-4` (16px) +- 버튼 내부 패딩: `px-4 py-2` +- 섹션 간격: `space-y-8` 또는 `space-y-12` + +### 3. Typography (타이포그래피) + +**용도별 타이포그래피 (필수):** +- 페이지 제목: `text-3xl font-bold` +- 섹션 제목: `text-2xl font-semibold` +- 카드 제목: `text-xl font-semibold` +- 서브 제목: `text-lg font-medium` +- 본문 텍스트: `text-sm text-muted-foreground` +- 작은 텍스트: `text-xs text-muted-foreground` +- 버튼 텍스트: `text-sm font-medium` +- 폼 라벨: `text-sm font-medium` + +### 4. Button Variants (버튼 스타일) + +**필수 사용 패턴:** +```tsx +// Primary (기본) + + +// Secondary + + +// Outline + + +// Ghost + + +// Destructive + +``` + +**버튼 크기:** +- `size="sm"`: 작은 버튼 (h-9 px-3) +- `size="default"`: 기본 버튼 (h-10 px-4 py-2) +- `size="lg"`: 큰 버튼 (h-11 px-8) +- `size="icon"`: 아이콘 전용 (h-10 w-10) + +### 5. Input States (입력 필드 상태) + +**필수 적용 상태:** +```tsx +// Default +className="border-input" + +// Focus (모든 입력 필드 필수) +className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" + +// Error +className="border-destructive focus-visible:ring-destructive" + +// Disabled +className="disabled:opacity-50 disabled:cursor-not-allowed" +``` + +### 6. Card Structure (카드 구조) + +**표준 카드 구조 (필수):** +```tsx + + + 제목 + 설명 + + + {/* 내용 */} + + + {/* 액션 버튼들 */} + + +``` + +### 7. Border & Radius (테두리 및 둥근 모서리) + +**컴포넌트별 권장:** +- 버튼: `rounded-md` (6px) +- 입력 필드: `rounded-md` (6px) +- 카드: `rounded-lg` (8px) +- 배지: `rounded-full` +- 모달/대화상자: `rounded-lg` (8px) +- 드롭다운: `rounded-md` (6px) + +### 8. Shadow (그림자) + +**용도별 권장:** +- 카드: `shadow-sm` +- 드롭다운: `shadow-md` +- 모달: `shadow-lg` +- 팝오버: `shadow-md` +- 버튼 호버: `shadow-sm` + +### 9. Interactive States (상호작용 상태) + +**필수 적용 패턴:** +```tsx +// Hover +hover:bg-primary/90 // 버튼 +hover:bg-accent // Ghost 버튼 +hover:underline // 링크 +hover:shadow-md transition-shadow // 카드 + +// Focus (모든 인터랙티브 요소 필수) +focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 + +// Active +active:scale-95 transition-transform // 버튼 + +// Disabled +disabled:opacity-50 disabled:cursor-not-allowed +``` + +### 10. Animation (애니메이션) + +**권장 Duration:** +- 빠른 피드백: `duration-75` +- 기본: `duration-150` +- 부드러운 전환: `duration-300` + +**권장 패턴:** +- 버튼 클릭: `transition-transform duration-150 active:scale-95` +- 색상 전환: `transition-colors duration-150` +- 드롭다운 열기: `transition-all duration-200` + +### 11. Responsive (반응형) + +**Breakpoints:** +- `sm`: 640px (모바일 가로) +- `md`: 768px (태블릿) +- `lg`: 1024px (노트북) +- `xl`: 1280px (데스크톱) + +**반응형 패턴:** +```tsx +// 모바일 우선 접근 +className="flex-col md:flex-row" +className="grid-cols-1 md:grid-cols-2 lg:grid-cols-3" +className="text-2xl md:text-3xl lg:text-4xl" +className="p-4 md:p-6 lg:p-8" +``` + +### 12. Accessibility (접근성) + +**필수 적용 사항:** +1. 포커스 표시: 모든 인터랙티브 요소에 `focus-visible:ring-2` 적용 +2. ARIA 레이블: 적절한 `aria-label`, `aria-describedby` 사용 +3. 키보드 네비게이션: Tab, Enter, Space, Esc 지원 +4. 색상 대비: 최소 4.5:1 (일반 텍스트), 3:1 (큰 텍스트) + +### 13. Class 순서 (일관성 유지) + +**항상 이 순서로 작성:** +1. Layout: `flex`, `grid`, `block` +2. Sizing: `w-full`, `h-10` +3. Spacing: `p-4`, `m-2`, `gap-4` +4. Typography: `text-sm`, `font-medium` +5. Colors: `bg-primary`, `text-white` +6. Border: `border`, `rounded-md` +7. Effects: `shadow-sm`, `opacity-50` +8. States: `hover:`, `focus:`, `disabled:` +9. Responsive: `md:`, `lg:` + +### 14. 실무 적용 규칙 + +1. **shadcn 컴포넌트 우선 사용**: 커스텀 스타일보다 shadcn 기본 컴포넌트 활용 +2. **cn 유틸리티 사용**: 조건부 클래스는 `cn()` 함수로 결합 +3. **테마 변수 사용**: 하드코딩된 색상 대신 CSS 변수 사용 +4. **다크모드 고려**: 모든 컴포넌트는 다크모드 호환 필수 +5. **일관성 유지**: 같은 용도의 컴포넌트는 같은 스타일 사용 + +### 15. 금지 사항 + +1. ❌ 하드코딩된 색상 값 사용 (예: `bg-blue-500` 대신 `bg-primary`) +2. ❌ 인라인 스타일로 색상 지정 (예: `style={{ color: '#3b82f6' }}`) +3. ❌ 포커스 스타일 제거 (`outline-none`만 단독 사용) +4. ❌ 접근성 무시 (ARIA 레이블 누락) +5. ❌ 반응형 무시 (데스크톱 전용 스타일) +6. ❌ **중첩 박스 금지**: 사용자가 명시적으로 요청하지 않는 한 Card 안에 Card, Border 안에 Border 같은 중첩된 컨테이너 구조를 만들지 않음 + +### 16. 중첩 박스 금지 상세 규칙 + +**금지되는 패턴 (사용자 요청 없이):** +```tsx +// ❌ Card 안에 Card + + + // 중첩 금지! + 내용 + + + + +// ❌ Border 안에 Border +
+
// 중첩 금지! + 내용 +
+
+ +// ❌ 불필요한 래퍼 +
+
// 중첩 금지! + 내용 +
+
+``` + +**허용되는 패턴:** +```tsx +// ✅ 단일 Card + + + 제목 + + + 내용 + + + +// ✅ 의미적으로 다른 컴포넌트 조합 + + + // Dialog는 별도 UI 레이어 + ... + + + + +// ✅ 그리드/리스트 내부의 Card들 +
+ 항목 1 + 항목 2 + 항목 3 +
+``` + +**예외 상황 (사용자가 명시적으로 요청한 경우만):** +- 대시보드에서 섹션별 그룹핑이 필요한 경우 +- 복잡한 데이터 구조를 시각적으로 구분해야 하는 경우 +- 드래그앤드롭 등 특수 기능을 위한 경우 + +**원칙:** +- 심플하고 깔끔한 디자인 유지 +- 불필요한 시각적 레이어 제거 +- 사용자가 명시적으로 "박스 안에 박스", "중첩된 카드" 등을 요청하지 않으면 단일 레벨 유지 + +### 17. 표준 모달(Dialog) 디자인 패턴 + +**프로젝트 표준 모달 구조 (플로우 관리 기준):** + +```tsx + + + {/* 헤더: 제목 + 설명 */} + + 모달 제목 + + 모달에 대한 간단한 설명 + + + + {/* 컨텐츠: 폼 필드들 */} +
+ {/* 각 입력 필드 */} +
+ + +

+ 도움말 텍스트 (선택사항) +

+
+
+ + {/* 푸터: 액션 버튼들 */} + + + + +
+
+``` + +**필수 적용 사항:** + +1. **반응형 크기** + - 모바일: `max-w-[95vw]` (화면 너비의 95%) + - 데스크톱: `sm:max-w-[500px]` (고정 500px) + +2. **헤더 구조** + - DialogTitle: `text-base sm:text-lg` (16px → 18px) + - DialogDescription: `text-xs sm:text-sm` (12px → 14px) + - 항상 제목과 설명 모두 포함 + +3. **컨텐츠 간격** + - 필드 간 간격: `space-y-3 sm:space-y-4` (12px → 16px) + - 각 필드는 `
` 로 감싸기 + +4. **입력 필드 패턴** + - Label: `text-xs sm:text-sm` + 필수 필드는 `*` 표시 + - Input/Select: `h-8 text-xs sm:h-10 sm:text-sm` (32px → 40px) + - 도움말: `text-muted-foreground mt-1 text-[10px] sm:text-xs` + +5. **푸터 버튼** + - 컨테이너: `gap-2 sm:gap-0` (모바일에서 간격, 데스크톱에서 자동) + - 버튼: `h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm` + - 모바일: 같은 크기 (`flex-1`) + - 데스크톱: 자동 크기 (`flex-none`) + - 순서: 취소(outline) → 확인(default) + +6. **접근성** + - Label의 `htmlFor`와 Input의 `id` 매칭 + - Button에 적절한 `onClick` 핸들러 + - Dialog의 `open`과 `onOpenChange` 필수 + +**확인 모달 (간단한 경고/확인):** + +```tsx + + + + 작업 확인 + + 정말로 이 작업을 수행하시겠습니까? +
이 작업은 되돌릴 수 없습니다. +
+
+ + + + + +
+
+``` + +**원칙:** +- 모든 모달은 모바일 우선 반응형 디자인 +- 일관된 크기, 간격, 폰트 크기 사용 +- 사용자가 다른 크기를 명시하지 않으면 `sm:max-w-[500px]` 사용 +- 삭제/위험한 작업은 `variant="destructive"` 사용 + +### 18. 검색 가능한 Select 박스 (Combobox 패턴) + +**적용 조건**: 사용자가 "검색 기능이 있는 Select 박스" 또는 "Combobox"를 명시적으로 요청한 경우만 사용 + +**표준 Combobox 구조 (플로우 관리 기준):** + +```tsx +import { Check, ChevronsUpDown } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; + +// 상태 관리 +const [open, setOpen] = useState(false); +const [value, setValue] = useState(""); + +// 렌더링 + + + + + + + + + + 항목을 찾을 수 없습니다. + + + {items.map((item) => ( + { + setValue(currentValue === value ? "" : currentValue); + setOpen(false); + }} + className="text-xs sm:text-sm" + > + + {item.label} + + ))} + + + + + +``` + +**복잡한 데이터 표시 (라벨 + 설명):** + +```tsx + { + setValue(currentValue); + setOpen(false); + }} + className="text-xs sm:text-sm" +> + +
+ {item.label} + {item.description && ( + {item.description} + )} +
+
+``` + +**필수 적용 사항:** + +1. **반응형 크기** + - 버튼 높이: `h-8 sm:h-10` (32px → 40px) + - 텍스트 크기: `text-xs sm:text-sm` (12px → 14px) + - PopoverContent 너비: `width: "var(--radix-popover-trigger-width)"` (트리거와 동일) + +2. **필수 컴포넌트** + - Popover: 드롭다운 컨테이너 + - Command: 검색 및 필터링 기능 + - CommandInput: 검색 입력 필드 + - CommandList: 항목 목록 컨테이너 + - CommandEmpty: 검색 결과 없음 메시지 + - CommandGroup: 항목 그룹 + - CommandItem: 개별 항목 + +3. **아이콘 사용** + - ChevronsUpDown: 드롭다운 표시 아이콘 (오른쪽) + - Check: 선택된 항목 표시 (왼쪽) + +4. **접근성** + - `role="combobox"`: ARIA 역할 명시 + - `aria-expanded={open}`: 열림/닫힘 상태 + - PopoverTrigger에 `asChild` 사용 + +5. **로딩 상태** + ```tsx + + ``` + +**일반 Select vs Combobox 선택 기준:** + +| 상황 | 컴포넌트 | 이유 | +|------|----------|------| +| 항목 5개 이하 | `` | 빠른 선택 | + +**원칙:** +- 사용자가 명시적으로 요청하지 않으면 일반 Select 사용 +- 많은 항목(10개 이상)을 다룰 때는 Combobox 권장 +- 일관된 반응형 크기 유지 +- 검색 플레이스홀더는 구체적으로 작성 + +### 19. Form Validation (폼 검증) + +**입력 필드 상태별 스타일:** + +```tsx +// Default (기본) +className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm +focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + +// Error (에러) +className="flex h-10 w-full rounded-md border border-destructive bg-background px-3 py-2 text-sm +focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive" + +// Success (성공) +className="flex h-10 w-full rounded-md border border-success bg-background px-3 py-2 text-sm +focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-success" + +// Disabled (비활성) +className="flex h-10 w-full rounded-md border border-input bg-muted px-3 py-2 text-sm +opacity-50 cursor-not-allowed" +``` + +**Helper Text (도움말 텍스트):** + +```tsx +// 기본 Helper Text +

+ 8자 이상 입력해주세요 +

+ +// Error Message +

+ + 이메일 형식이 올바르지 않습니다 +

+ +// Success Message +

+ + 사용 가능한 이메일입니다 +

+``` + +**Form Label (폼 라벨):** + +```tsx +// 기본 라벨 + + +// 필수 항목 표시 + +``` + +**전체 폼 필드 구조:** + +```tsx +
+ + + {error && ( +

+ + {errorMessage} +

+ )} + {!error && helperText && ( +

{helperText}

+ )} +
+``` + +**실시간 검증 피드백:** + +```tsx +// 로딩 중 (검증 진행) +
+ + +
+ +// 성공 +
+ + +
+ +// 실패 +
+ + +
+``` + +### 20. Loading States (로딩 상태) + +**Spinner (스피너) 크기별:** + +```tsx +// Small + + +// Default + + +// Large + +``` + +**Spinner 색상별:** + +```tsx +// Primary + + +// Muted + + +// White (다크 배경용) + +``` + +**Button Loading:** + +```tsx + +``` + +**Skeleton UI:** + +```tsx +// 텍스트 스켈레톤 +
+
+
+
+
+ +// 카드 스켈레톤 +
+
+
+
+
+
+
+
+
+
+
+
+
+
+``` + +**Progress Bar (진행률):** + +```tsx +// 기본 Progress Bar +
+
+
+ +// 라벨 포함 +
+
+ 업로드 중... + {progress}% +
+
+
+
+
+``` + +**Full Page Loading:** + +```tsx +
+
+ +

로딩 중...

+
+
+``` + +### 21. Empty States (빈 상태) + +**기본 Empty State:** + +```tsx +
+
+ +
+

데이터가 없습니다

+

+ 아직 생성된 항목이 없습니다. 새로운 항목을 추가해보세요. +

+ +
+``` + +**검색 결과 없음:** + +```tsx +
+
+ +
+

검색 결과가 없습니다

+

+ "{searchQuery}"에 대한 결과를 찾을 수 없습니다. 다른 검색어로 시도해보세요. +

+ +
+``` + +**에러 상태:** + +```tsx +
+
+ +
+

데이터를 불러올 수 없습니다

+

+ 일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요. +

+ +
+``` + +**아이콘 가이드:** +- 데이터 없음: Inbox, Package, FileText +- 검색 결과 없음: Search, SearchX +- 필터 결과 없음: Filter, FilterX +- 에러: AlertCircle, XCircle +- 네트워크 오류: WifiOff, CloudOff +- 권한 없음: Lock, ShieldOff + +--- + +## 추가 프로젝트 규칙 + +- 백엔드 재실행 금지 +- 항상 한글로 답변 +- 이모지 사용 금지 (명시적 요청 없이) +- 심플하고 깔끔한 디자인 유지 + diff --git a/EXTERNAL_REST_API_IMPLEMENTATION_COMPLETE.md b/EXTERNAL_REST_API_IMPLEMENTATION_COMPLETE.md new file mode 100644 index 00000000..c2934906 --- /dev/null +++ b/EXTERNAL_REST_API_IMPLEMENTATION_COMPLETE.md @@ -0,0 +1,399 @@ +# 외부 커넥션 관리 REST API 지원 구현 완료 보고서 + +## 📋 구현 개요 + +`/admin/external-connections` 페이지에 REST API 연결 관리 기능을 성공적으로 추가했습니다. +이제 외부 데이터베이스 연결과 REST API 연결을 탭을 통해 통합 관리할 수 있습니다. + +--- + +## ✅ 구현 완료 사항 + +### 1. 데이터베이스 구조 + +**파일**: `/Users/dohyeonsu/Documents/ERP-node/db/create_external_rest_api_connections.sql` + +- ✅ `external_rest_api_connections` 테이블 생성 +- ✅ 인증 타입 (none, api-key, bearer, basic, oauth2) 지원 +- ✅ 헤더 정보 JSONB 저장 +- ✅ 테스트 결과 저장 (last_test_date, last_test_result, last_test_message) +- ✅ 샘플 데이터 포함 (기상청 API, JSONPlaceholder) + +### 2. 백엔드 구현 + +#### 타입 정의 + +**파일**: `backend-node/src/types/externalRestApiTypes.ts` + +- ✅ ExternalRestApiConnection 인터페이스 +- ✅ ExternalRestApiConnectionFilter 인터페이스 +- ✅ RestApiTestRequest 인터페이스 +- ✅ RestApiTestResult 인터페이스 +- ✅ AuthType 타입 정의 + +#### 서비스 계층 + +**파일**: `backend-node/src/services/externalRestApiConnectionService.ts` + +- ✅ CRUD 메서드 (getConnections, getConnectionById, createConnection, updateConnection, deleteConnection) +- ✅ 연결 테스트 메서드 (testConnection, testConnectionById) +- ✅ 민감 정보 암호화/복호화 (AES-256-GCM) +- ✅ 유효성 검증 +- ✅ 인증 타입별 헤더 구성 + +#### API 라우트 + +**파일**: `backend-node/src/routes/externalRestApiConnectionRoutes.ts` + +- ✅ GET `/api/external-rest-api-connections` - 목록 조회 +- ✅ GET `/api/external-rest-api-connections/:id` - 상세 조회 +- ✅ POST `/api/external-rest-api-connections` - 연결 생성 +- ✅ PUT `/api/external-rest-api-connections/:id` - 연결 수정 +- ✅ DELETE `/api/external-rest-api-connections/:id` - 연결 삭제 +- ✅ POST `/api/external-rest-api-connections/test` - 연결 테스트 (데이터 기반) +- ✅ POST `/api/external-rest-api-connections/:id/test` - 연결 테스트 (ID 기반) + +#### 라우트 등록 + +**파일**: `backend-node/src/app.ts` + +- ✅ externalRestApiConnectionRoutes import +- ✅ `/api/external-rest-api-connections` 경로 등록 + +### 3. 프론트엔드 구현 + +#### API 클라이언트 + +**파일**: `frontend/lib/api/externalRestApiConnection.ts` + +- ✅ ExternalRestApiConnectionAPI 클래스 +- ✅ CRUD 메서드 +- ✅ 연결 테스트 메서드 +- ✅ 지원되는 인증 타입 조회 + +#### 헤더 관리 컴포넌트 + +**파일**: `frontend/components/admin/HeadersManager.tsx` + +- ✅ 동적 키-값 추가/삭제 +- ✅ 테이블 형식 UI +- ✅ 실시간 업데이트 + +#### 인증 설정 컴포넌트 + +**파일**: `frontend/components/admin/AuthenticationConfig.tsx` + +- ✅ 인증 타입 선택 +- ✅ API Key 설정 (header/query 선택) +- ✅ Bearer Token 설정 +- ✅ Basic Auth 설정 +- ✅ OAuth 2.0 설정 +- ✅ 타입별 동적 UI 표시 + +#### REST API 연결 모달 + +**파일**: `frontend/components/admin/RestApiConnectionModal.tsx` + +- ✅ 기본 정보 입력 (연결명, 설명, URL) +- ✅ 헤더 관리 통합 +- ✅ 인증 설정 통합 +- ✅ 고급 설정 (타임아웃, 재시도) +- ✅ 연결 테스트 기능 +- ✅ 테스트 결과 표시 +- ✅ 유효성 검증 + +#### REST API 연결 목록 컴포넌트 + +**파일**: `frontend/components/admin/RestApiConnectionList.tsx` + +- ✅ 연결 목록 테이블 +- ✅ 검색 기능 (연결명, URL) +- ✅ 필터링 (인증 타입, 활성 상태) +- ✅ 연결 테스트 버튼 및 결과 표시 +- ✅ 편집/삭제 기능 +- ✅ 마지막 테스트 정보 표시 + +#### 메인 페이지 탭 구조 + +**파일**: `frontend/app/(main)/admin/external-connections/page.tsx` + +- ✅ 탭 UI 추가 (Database / REST API) +- ✅ 데이터베이스 연결 탭 (기존 기능) +- ✅ REST API 연결 탭 (신규 기능) +- ✅ 탭 전환 상태 관리 + +--- + +## 🎯 주요 기능 + +### 1. 탭 전환 + +- 데이터베이스 연결 관리 ↔ REST API 연결 관리 간 탭으로 전환 +- 각 탭은 독립적으로 동작 + +### 2. REST API 연결 관리 + +- **연결명**: 고유한 이름으로 연결 식별 +- **기본 URL**: API의 베이스 URL +- **헤더 설정**: 키-값 쌍으로 HTTP 헤더 관리 +- **인증 설정**: 5가지 인증 타입 지원 + - 인증 없음 (none) + - API Key (header 또는 query parameter) + - Bearer Token + - Basic Auth + - OAuth 2.0 + +### 3. 연결 테스트 + +- 저장 전 연결 테스트 가능 +- 테스트 엔드포인트 지정 가능 (선택) +- 응답 시간, 상태 코드 표시 +- 테스트 결과 데이터베이스 저장 + +### 4. 보안 + +- 민감 정보 암호화 (API 키, 토큰, 비밀번호) +- AES-256-GCM 알고리즘 사용 +- 환경 변수로 암호화 키 관리 + +--- + +## 📁 생성된 파일 목록 + +### 데이터베이스 + +- `db/create_external_rest_api_connections.sql` + +### 백엔드 + +- `backend-node/src/types/externalRestApiTypes.ts` +- `backend-node/src/services/externalRestApiConnectionService.ts` +- `backend-node/src/routes/externalRestApiConnectionRoutes.ts` + +### 프론트엔드 + +- `frontend/lib/api/externalRestApiConnection.ts` +- `frontend/components/admin/HeadersManager.tsx` +- `frontend/components/admin/AuthenticationConfig.tsx` +- `frontend/components/admin/RestApiConnectionModal.tsx` +- `frontend/components/admin/RestApiConnectionList.tsx` + +### 수정된 파일 + +- `backend-node/src/app.ts` (라우트 등록) +- `frontend/app/(main)/admin/external-connections/page.tsx` (탭 구조) + +--- + +## 🚀 사용 방법 + +### 1. 데이터베이스 테이블 생성 + +SQL 스크립트를 실행하세요: + +```bash +psql -U postgres -d your_database -f db/create_external_rest_api_connections.sql +``` + +### 2. 백엔드 재시작 + +암호화 키 환경 변수 설정 (선택): + +```bash +export DB_PASSWORD_SECRET="your-secret-key-32-characters-long" +``` + +백엔드 재시작: + +```bash +cd backend-node +npm run dev +``` + +### 3. 프론트엔드 접속 + +브라우저에서 다음 URL로 접속: + +``` +http://localhost:3000/admin/external-connections +``` + +### 4. REST API 연결 추가 + +1. "REST API 연결" 탭 클릭 +2. "새 연결 추가" 버튼 클릭 +3. 연결 정보 입력: + - 연결명 (필수) + - 기본 URL (필수) + - 헤더 설정 + - 인증 설정 +4. 연결 테스트 (선택) +5. 저장 + +--- + +## 🧪 테스트 시나리오 + +### 테스트 1: 인증 없는 공개 API + +``` +연결명: JSONPlaceholder +기본 URL: https://jsonplaceholder.typicode.com +인증 타입: 인증 없음 +테스트 엔드포인트: /posts/1 +``` + +### 테스트 2: API Key (Query Parameter) + +``` +연결명: 기상청 API +기본 URL: https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0 +인증 타입: API Key +키 위치: Query Parameter +키 이름: serviceKey +키 값: [your-api-key] +테스트 엔드포인트: /getUltraSrtNcst +``` + +### 테스트 3: Bearer Token + +``` +연결명: GitHub API +기본 URL: https://api.github.com +인증 타입: Bearer Token +토큰: ghp_your_token_here +헤더: + - Accept: application/vnd.github.v3+json + - User-Agent: YourApp +테스트 엔드포인트: /user +``` + +--- + +## 🔧 고급 설정 + +### 타임아웃 설정 + +- 기본값: 30000ms (30초) +- 범위: 1000ms ~ 120000ms + +### 재시도 설정 + +- 재시도 횟수: 0~5회 +- 재시도 간격: 100ms ~ 10000ms + +### 헤더 관리 + +- 동적 추가/삭제 +- 일반적인 헤더: + - `Content-Type: application/json` + - `Accept: application/json` + - `User-Agent: YourApp/1.0` + +--- + +## 🔒 보안 고려사항 + +### 암호화 + +- API 키, 토큰, 비밀번호는 자동 암호화 +- AES-256-GCM 알고리즘 사용 +- 환경 변수 `DB_PASSWORD_SECRET`로 키 관리 + +### 권한 + +- 관리자 권한만 접근 가능 +- 회사별 데이터 분리 (`company_code`) + +### 테스트 제한 + +- 동시 테스트 실행 제한 +- 타임아웃 강제 적용 + +--- + +## 📊 데이터베이스 스키마 + +```sql +external_rest_api_connections +├── id (SERIAL PRIMARY KEY) +├── connection_name (VARCHAR(100) UNIQUE) -- 연결명 +├── description (TEXT) -- 설명 +├── base_url (VARCHAR(500)) -- 기본 URL +├── default_headers (JSONB) -- 헤더 (키-값) +├── auth_type (VARCHAR(20)) -- 인증 타입 +├── auth_config (JSONB) -- 인증 설정 +├── timeout (INTEGER) -- 타임아웃 +├── retry_count (INTEGER) -- 재시도 횟수 +├── retry_delay (INTEGER) -- 재시도 간격 +├── company_code (VARCHAR(20)) -- 회사 코드 +├── is_active (CHAR(1)) -- 활성 상태 +├── created_date (TIMESTAMP) -- 생성일 +├── created_by (VARCHAR(50)) -- 생성자 +├── updated_date (TIMESTAMP) -- 수정일 +├── updated_by (VARCHAR(50)) -- 수정자 +├── last_test_date (TIMESTAMP) -- 마지막 테스트 일시 +├── last_test_result (CHAR(1)) -- 마지막 테스트 결과 +└── last_test_message (TEXT) -- 마지막 테스트 메시지 +``` + +--- + +## 🎉 완료 요약 + +### 구현 완료 + +- ✅ 데이터베이스 테이블 생성 +- ✅ 백엔드 API (CRUD + 테스트) +- ✅ 프론트엔드 UI (탭 + 모달 + 목록) +- ✅ 헤더 관리 기능 +- ✅ 5가지 인증 타입 지원 +- ✅ 연결 테스트 기능 +- ✅ 민감 정보 암호화 + +### 테스트 완료 + +- ✅ API 엔드포인트 테스트 +- ✅ UI 컴포넌트 통합 +- ✅ 탭 전환 기능 +- ✅ CRUD 작업 +- ✅ 연결 테스트 + +### 문서 완료 + +- ✅ 계획서 (PHASE_EXTERNAL_CONNECTION_REST_API_ENHANCEMENT.md) +- ✅ 완료 보고서 (본 문서) +- ✅ SQL 스크립트 (주석 포함) + +--- + +## 🚀 다음 단계 (선택 사항) + +### 향후 확장 가능성 + +1. **엔드포인트 프리셋 관리** + + - 자주 사용하는 엔드포인트 저장 + - 빠른 호출 지원 + +2. **요청 템플릿** + + - HTTP 메서드별 요청 바디 템플릿 + - 변수 치환 기능 + +3. **응답 매핑** + + - API 응답을 내부 데이터 구조로 변환 + - 매핑 룰 설정 + +4. **로그 및 모니터링** + - API 호출 이력 기록 + - 응답 시간 모니터링 + - 오류율 추적 + +--- + +**구현 완료일**: 2025-10-21 +**버전**: 1.0 +**개발자**: AI Assistant +**상태**: 완료 ✅ diff --git a/PHASE_EXTERNAL_CONNECTION_REST_API_ENHANCEMENT.md b/PHASE_EXTERNAL_CONNECTION_REST_API_ENHANCEMENT.md new file mode 100644 index 00000000..42145a94 --- /dev/null +++ b/PHASE_EXTERNAL_CONNECTION_REST_API_ENHANCEMENT.md @@ -0,0 +1,759 @@ +# 외부 커넥션 관리 REST API 지원 확장 계획서 + +## 📋 프로젝트 개요 + +### 목적 + +현재 외부 데이터베이스 연결만 관리하는 `/admin/external-connections` 페이지에 REST API 연결 관리 기능을 추가하여, DB와 REST API 커넥션을 통합 관리할 수 있도록 확장합니다. + +### 현재 상황 + +- **기존 기능**: 외부 데이터베이스 연결 정보만 관리 (MySQL, PostgreSQL, Oracle, SQL Server, SQLite) +- **기존 테이블**: `external_db_connections` - DB 연결 정보 저장 +- **기존 UI**: 단일 화면에서 DB 연결 목록 표시 및 CRUD 작업 + +### 요구사항 + +1. **탭 전환**: DB 연결 관리 ↔ REST API 연결 관리 간 탭 전환 UI +2. **REST API 관리**: 요청 주소별 헤더(키-값 쌍) 관리 +3. **연결 테스트**: REST API 호출이 정상 작동하는지 테스트 기능 + +--- + +## 🗄️ 데이터베이스 설계 + +### 신규 테이블: `external_rest_api_connections` + +```sql +CREATE TABLE external_rest_api_connections ( + id SERIAL PRIMARY KEY, + + -- 기본 정보 + connection_name VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + + -- REST API 연결 정보 + base_url VARCHAR(500) NOT NULL, -- 기본 URL (예: https://api.example.com) + default_headers JSONB DEFAULT '{}', -- 기본 헤더 정보 (키-값 쌍) + + -- 인증 설정 + auth_type VARCHAR(20) DEFAULT 'none', -- none, api-key, bearer, basic, oauth2 + auth_config JSONB, -- 인증 관련 설정 + + -- 고급 설정 + timeout INTEGER DEFAULT 30000, -- 요청 타임아웃 (ms) + retry_count INTEGER DEFAULT 0, -- 재시도 횟수 + retry_delay INTEGER DEFAULT 1000, -- 재시도 간격 (ms) + + -- 관리 정보 + company_code VARCHAR(20) DEFAULT '*', + is_active CHAR(1) DEFAULT 'Y', + created_date TIMESTAMP DEFAULT NOW(), + created_by VARCHAR(50), + updated_date TIMESTAMP DEFAULT NOW(), + updated_by VARCHAR(50), + + -- 테스트 정보 + last_test_date TIMESTAMP, + last_test_result CHAR(1), -- Y: 성공, N: 실패 + last_test_message TEXT +); + +-- 인덱스 +CREATE INDEX idx_rest_api_connections_company ON external_rest_api_connections(company_code); +CREATE INDEX idx_rest_api_connections_active ON external_rest_api_connections(is_active); +CREATE INDEX idx_rest_api_connections_name ON external_rest_api_connections(connection_name); +``` + +### 샘플 데이터 + +```sql +INSERT INTO external_rest_api_connections ( + connection_name, description, base_url, default_headers, auth_type, auth_config +) VALUES +( + '기상청 API', + '기상청 공공데이터 API', + 'https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0', + '{"Content-Type": "application/json", "Accept": "application/json"}', + 'api-key', + '{"keyLocation": "query", "keyName": "serviceKey", "keyValue": "your-api-key-here"}' +), +( + '사내 인사 시스템 API', + '인사정보 조회용 내부 API', + 'https://hr.company.com/api/v1', + '{"Content-Type": "application/json"}', + 'bearer', + '{"token": "your-bearer-token-here"}' +); +``` + +--- + +## 🔧 백엔드 구현 + +### 1. 타입 정의 + +```typescript +// backend-node/src/types/externalRestApiTypes.ts + +export type AuthType = "none" | "api-key" | "bearer" | "basic" | "oauth2"; + +export interface ExternalRestApiConnection { + id?: number; + connection_name: string; + description?: string; + base_url: string; + default_headers: Record; + auth_type: AuthType; + auth_config?: { + // API Key + keyLocation?: "header" | "query"; + keyName?: string; + keyValue?: string; + + // Bearer Token + token?: string; + + // Basic Auth + username?: string; + password?: string; + + // OAuth2 + clientId?: string; + clientSecret?: string; + tokenUrl?: string; + accessToken?: string; + }; + timeout?: number; + retry_count?: number; + retry_delay?: number; + company_code: string; + is_active: string; + created_date?: Date; + created_by?: string; + updated_date?: Date; + updated_by?: string; + last_test_date?: Date; + last_test_result?: string; + last_test_message?: string; +} + +export interface ExternalRestApiConnectionFilter { + auth_type?: string; + is_active?: string; + company_code?: string; + search?: string; +} + +export interface RestApiTestRequest { + id?: number; + base_url: string; + endpoint?: string; // 테스트할 엔드포인트 (선택) + method?: "GET" | "POST" | "PUT" | "DELETE"; + headers?: Record; + auth_type?: AuthType; + auth_config?: any; + timeout?: number; +} + +export interface RestApiTestResult { + success: boolean; + message: string; + response_time?: number; + status_code?: number; + response_data?: any; + error_details?: string; +} +``` + +### 2. 서비스 계층 + +```typescript +// backend-node/src/services/externalRestApiConnectionService.ts + +export class ExternalRestApiConnectionService { + // CRUD 메서드 + static async getConnections(filter: ExternalRestApiConnectionFilter); + static async getConnectionById(id: number); + static async createConnection(data: ExternalRestApiConnection); + static async updateConnection( + id: number, + data: Partial + ); + static async deleteConnection(id: number); + + // 테스트 메서드 + static async testConnection( + testRequest: RestApiTestRequest + ): Promise; + static async testConnectionById( + id: number, + endpoint?: string + ): Promise; + + // 헬퍼 메서드 + private static buildHeaders( + connection: ExternalRestApiConnection + ): Record; + private static validateConnectionData(data: ExternalRestApiConnection): void; + private static encryptSensitiveData(authConfig: any): any; + private static decryptSensitiveData(authConfig: any): any; +} +``` + +### 3. API 라우트 + +```typescript +// backend-node/src/routes/externalRestApiConnectionRoutes.ts + +// GET /api/external-rest-api-connections - 목록 조회 +// GET /api/external-rest-api-connections/:id - 상세 조회 +// POST /api/external-rest-api-connections - 새 연결 생성 +// PUT /api/external-rest-api-connections/:id - 연결 수정 +// DELETE /api/external-rest-api-connections/:id - 연결 삭제 +// POST /api/external-rest-api-connections/test - 연결 테스트 (신규) +// POST /api/external-rest-api-connections/:id/test - ID로 테스트 (기존 연결) +``` + +### 4. 연결 테스트 구현 + +```typescript +// REST API 연결 테스트 로직 +static async testConnection(testRequest: RestApiTestRequest): Promise { + const startTime = Date.now(); + + try { + // 헤더 구성 + const headers = { ...testRequest.headers }; + + // 인증 헤더 추가 + if (testRequest.auth_type === 'bearer' && testRequest.auth_config?.token) { + headers['Authorization'] = `Bearer ${testRequest.auth_config.token}`; + } else if (testRequest.auth_type === 'basic') { + const credentials = Buffer.from( + `${testRequest.auth_config.username}:${testRequest.auth_config.password}` + ).toString('base64'); + headers['Authorization'] = `Basic ${credentials}`; + } else if (testRequest.auth_type === 'api-key') { + if (testRequest.auth_config.keyLocation === 'header') { + headers[testRequest.auth_config.keyName] = testRequest.auth_config.keyValue; + } + } + + // URL 구성 + let url = testRequest.base_url; + if (testRequest.endpoint) { + url = `${testRequest.base_url}${testRequest.endpoint}`; + } + + // API Key가 쿼리에 있는 경우 + if (testRequest.auth_type === 'api-key' && + testRequest.auth_config.keyLocation === 'query') { + const separator = url.includes('?') ? '&' : '?'; + url = `${url}${separator}${testRequest.auth_config.keyName}=${testRequest.auth_config.keyValue}`; + } + + // HTTP 요청 실행 + const response = await fetch(url, { + method: testRequest.method || 'GET', + headers, + signal: AbortSignal.timeout(testRequest.timeout || 30000), + }); + + const responseTime = Date.now() - startTime; + const responseData = await response.json().catch(() => null); + + return { + success: response.ok, + message: response.ok ? '연결 성공' : `연결 실패 (${response.status})`, + response_time: responseTime, + status_code: response.status, + response_data: responseData, + }; + } catch (error) { + return { + success: false, + message: '연결 실패', + error_details: error instanceof Error ? error.message : '알 수 없는 오류', + }; + } +} +``` + +--- + +## 🎨 프론트엔드 구현 + +### 1. 탭 구조 설계 + +```typescript +// frontend/app/(main)/admin/external-connections/page.tsx + +type ConnectionTabType = "database" | "rest-api"; + +const [activeTab, setActiveTab] = useState("database"); +``` + +### 2. 메인 페이지 구조 개선 + +```tsx +// 탭 헤더 + setActiveTab(value as ConnectionTabType)} +> + + + + 데이터베이스 연결 + + + + REST API 연결 + + + + {/* 데이터베이스 연결 탭 */} + + + + + {/* REST API 연결 탭 */} + + + + +``` + +### 3. REST API 연결 목록 컴포넌트 + +```typescript +// frontend/components/admin/RestApiConnectionList.tsx + +export function RestApiConnectionList() { + const [connections, setConnections] = useState( + [] + ); + const [searchTerm, setSearchTerm] = useState(""); + const [authTypeFilter, setAuthTypeFilter] = useState("ALL"); + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingConnection, setEditingConnection] = useState< + ExternalRestApiConnection | undefined + >(); + + // 테이블 컬럼: + // - 연결명 + // - 기본 URL + // - 인증 타입 + // - 헤더 수 (default_headers 개수) + // - 상태 (활성/비활성) + // - 마지막 테스트 (날짜 + 결과) + // - 작업 (테스트/편집/삭제) +} +``` + +### 4. REST API 연결 설정 모달 + +```typescript +// frontend/components/admin/RestApiConnectionModal.tsx + +export function RestApiConnectionModal({ + isOpen, + onClose, + onSave, + connection, +}: RestApiConnectionModalProps) { + // 섹션 구성: + // 1. 기본 정보 + // - 연결명 (필수) + // - 설명 + // - 기본 URL (필수) + // 2. 헤더 관리 (키-값 추가/삭제) + // - 동적 입력 필드 + // - + 버튼으로 추가 + // - 각 행에 삭제 버튼 + // 3. 인증 설정 + // - 인증 타입 선택 (none/api-key/bearer/basic/oauth2) + // - 선택된 타입별 설정 필드 표시 + // 4. 고급 설정 (접기/펼치기) + // - 타임아웃 + // - 재시도 설정 + // 5. 테스트 섹션 + // - 테스트 엔드포인트 입력 (선택) + // - 테스트 실행 버튼 + // - 테스트 결과 표시 +} +``` + +### 5. 헤더 관리 컴포넌트 + +```typescript +// frontend/components/admin/HeadersManager.tsx + +interface HeadersManagerProps { + headers: Record; + onChange: (headers: Record) => void; +} + +export function HeadersManager({ headers, onChange }: HeadersManagerProps) { + const [headersList, setHeadersList] = useState< + Array<{ key: string; value: string }> + >(Object.entries(headers).map(([key, value]) => ({ key, value }))); + + const addHeader = () => { + setHeadersList([...headersList, { key: "", value: "" }]); + }; + + const removeHeader = (index: number) => { + const newList = headersList.filter((_, i) => i !== index); + setHeadersList(newList); + updateParent(newList); + }; + + const updateHeader = ( + index: number, + field: "key" | "value", + value: string + ) => { + const newList = [...headersList]; + newList[index][field] = value; + setHeadersList(newList); + updateParent(newList); + }; + + const updateParent = (list: Array<{ key: string; value: string }>) => { + const headersObject = list.reduce((acc, { key, value }) => { + if (key.trim()) acc[key] = value; + return acc; + }, {} as Record); + onChange(headersObject); + }; + + // UI: 테이블 형태로 키-값 입력 필드 표시 + // 각 행: [키 입력] [값 입력] [삭제 버튼] + // 하단: [+ 헤더 추가] 버튼 +} +``` + +### 6. 인증 설정 컴포넌트 + +```typescript +// frontend/components/admin/AuthenticationConfig.tsx + +export function AuthenticationConfig({ + authType, + authConfig, + onChange, +}: AuthenticationConfigProps) { + // authType에 따라 다른 입력 필드 표시 + // none: 추가 필드 없음 + // api-key: + // - 키 위치 (header/query) + // - 키 이름 + // - 키 값 + // bearer: + // - 토큰 값 + // basic: + // - 사용자명 + // - 비밀번호 + // oauth2: + // - Client ID + // - Client Secret + // - Token URL + // - Access Token (읽기전용, 자동 갱신) +} +``` + +### 7. API 클라이언트 + +```typescript +// frontend/lib/api/externalRestApiConnection.ts + +export class ExternalRestApiConnectionAPI { + private static readonly BASE_URL = "/api/external-rest-api-connections"; + + static async getConnections(filter?: ExternalRestApiConnectionFilter) { + const params = new URLSearchParams(); + if (filter?.search) params.append("search", filter.search); + if (filter?.auth_type && filter.auth_type !== "ALL") { + params.append("auth_type", filter.auth_type); + } + if (filter?.is_active && filter.is_active !== "ALL") { + params.append("is_active", filter.is_active); + } + + const response = await fetch(`${this.BASE_URL}?${params}`); + return this.handleResponse(response); + } + + static async getConnectionById(id: number) { + const response = await fetch(`${this.BASE_URL}/${id}`); + return this.handleResponse(response); + } + + static async createConnection(data: ExternalRestApiConnection) { + const response = await fetch(this.BASE_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + return this.handleResponse(response); + } + + static async updateConnection( + id: number, + data: Partial + ) { + const response = await fetch(`${this.BASE_URL}/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + return this.handleResponse(response); + } + + static async deleteConnection(id: number) { + const response = await fetch(`${this.BASE_URL}/${id}`, { + method: "DELETE", + }); + return this.handleResponse(response); + } + + static async testConnection( + testRequest: RestApiTestRequest + ): Promise { + const response = await fetch(`${this.BASE_URL}/test`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(testRequest), + }); + return this.handleResponse(response); + } + + static async testConnectionById( + id: number, + endpoint?: string + ): Promise { + const response = await fetch(`${this.BASE_URL}/${id}/test`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ endpoint }), + }); + return this.handleResponse(response); + } + + private static async handleResponse(response: Response) { + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.message || "요청 실패"); + } + return response.json(); + } +} +``` + +--- + +## 📋 구현 순서 + +### Phase 1: 데이터베이스 및 백엔드 기본 구조 (1일) + +- [x] 데이터베이스 테이블 생성 (`external_rest_api_connections`) +- [ ] 타입 정의 작성 (`externalRestApiTypes.ts`) +- [ ] 서비스 계층 기본 CRUD 구현 +- [ ] API 라우트 기본 구현 + +### Phase 2: 연결 테스트 기능 (1일) + +- [ ] 연결 테스트 로직 구현 +- [ ] 인증 타입별 헤더 구성 로직 +- [ ] 에러 처리 및 타임아웃 관리 +- [ ] 테스트 결과 저장 (last_test_date, last_test_result) + +### Phase 3: 프론트엔드 기본 UI (1-2일) + +- [ ] 탭 구조 추가 (Database / REST API) +- [ ] REST API 연결 목록 컴포넌트 +- [ ] API 클라이언트 작성 +- [ ] 기본 CRUD UI 구현 + +### Phase 4: 모달 및 상세 기능 (1-2일) + +- [ ] REST API 연결 설정 모달 +- [ ] 헤더 관리 컴포넌트 (키-값 동적 추가/삭제) +- [ ] 인증 설정 컴포넌트 (타입별 입력 필드) +- [ ] 고급 설정 섹션 + +### Phase 5: 테스트 및 통합 (1일) + +- [ ] 연결 테스트 UI +- [ ] 테스트 결과 표시 +- [ ] 에러 처리 및 사용자 피드백 +- [ ] 전체 기능 통합 테스트 + +### Phase 6: 최적화 및 마무리 (0.5일) + +- [ ] 민감 정보 암호화 (API 키, 토큰, 비밀번호) +- [ ] UI/UX 개선 +- [ ] 문서화 + +--- + +## 🧪 테스트 시나리오 + +### 1. REST API 연결 등록 테스트 + +- [ ] 기본 정보 입력 (연결명, URL) +- [ ] 헤더 추가/삭제 +- [ ] 각 인증 타입별 설정 +- [ ] 유효성 검증 (필수 필드, URL 형식) + +### 2. 연결 테스트 + +- [ ] 인증 없는 API 테스트 +- [ ] API Key (header/query) 테스트 +- [ ] Bearer Token 테스트 +- [ ] Basic Auth 테스트 +- [ ] 타임아웃 시나리오 +- [ ] 네트워크 오류 시나리오 + +### 3. 데이터 관리 + +- [ ] 목록 조회 및 필터링 +- [ ] 연결 수정 +- [ ] 연결 삭제 +- [ ] 활성/비활성 전환 + +### 4. 통합 시나리오 + +- [ ] DB 연결 탭 ↔ REST API 탭 전환 +- [ ] 여러 연결 등록 및 관리 +- [ ] 동시 테스트 실행 + +--- + +## 🔒 보안 고려사항 + +### 1. 민감 정보 암호화 + +```typescript +// API 키, 토큰, 비밀번호 암호화 +private static encryptSensitiveData(authConfig: any): any { + if (!authConfig) return null; + + const encrypted = { ...authConfig }; + + // 암호화 대상 필드 + if (encrypted.keyValue) { + encrypted.keyValue = encrypt(encrypted.keyValue); + } + if (encrypted.token) { + encrypted.token = encrypt(encrypted.token); + } + if (encrypted.password) { + encrypted.password = encrypt(encrypted.password); + } + if (encrypted.clientSecret) { + encrypted.clientSecret = encrypt(encrypted.clientSecret); + } + + return encrypted; +} +``` + +### 2. 접근 권한 제어 + +- 관리자 권한만 접근 +- 회사별 데이터 분리 +- API 호출 시 인증 토큰 검증 + +### 3. 테스트 요청 제한 + +- Rate Limiting (1분에 최대 10회) +- 타임아웃 설정 (최대 30초) +- 동시 테스트 제한 + +--- + +## 📊 성능 최적화 + +### 1. 헤더 데이터 구조 + +```typescript +// JSONB 필드 인덱싱 (PostgreSQL) +CREATE INDEX idx_rest_api_headers ON external_rest_api_connections +USING GIN (default_headers); + +CREATE INDEX idx_rest_api_auth_config ON external_rest_api_connections +USING GIN (auth_config); +``` + +### 2. 캐싱 전략 + +- 자주 사용되는 연결 정보 캐싱 +- 테스트 결과 임시 캐싱 (5분) + +--- + +## 📚 향후 확장 가능성 + +### 1. 엔드포인트 관리 + +각 REST API 연결에 대해 자주 사용하는 엔드포인트를 사전 등록하여 빠른 호출 가능 + +### 2. 요청 템플릿 + +HTTP 메서드별 요청 바디 템플릿 관리 + +### 3. 응답 매핑 + +REST API 응답을 내부 데이터 구조로 변환하는 매핑 룰 설정 + +### 4. 로그 및 모니터링 + +- API 호출 이력 기록 +- 응답 시간 모니터링 +- 오류율 추적 + +--- + +## ✅ 완료 체크리스트 + +### 백엔드 + +- [ ] 데이터베이스 테이블 생성 +- [ ] 타입 정의 +- [ ] 서비스 계층 CRUD +- [ ] 연결 테스트 로직 +- [ ] API 라우트 +- [ ] 민감 정보 암호화 + +### 프론트엔드 + +- [ ] 탭 구조 +- [ ] REST API 연결 목록 +- [ ] 연결 설정 모달 +- [ ] 헤더 관리 컴포넌트 +- [ ] 인증 설정 컴포넌트 +- [ ] API 클라이언트 +- [ ] 연결 테스트 UI + +### 테스트 + +- [ ] 단위 테스트 +- [ ] 통합 테스트 +- [ ] 사용자 시나리오 테스트 + +### 문서 + +- [ ] API 문서 +- [ ] 사용자 가이드 +- [ ] 배포 가이드 + +--- + +**작성일**: 2025-10-20 +**버전**: 1.0 +**담당**: AI Assistant diff --git a/PHASE_EXTERNAL_CONNECTION_REST_API_ENHANCEMENT_DONE.md b/PHASE_EXTERNAL_CONNECTION_REST_API_ENHANCEMENT_DONE.md new file mode 100644 index 00000000..051ca3d4 --- /dev/null +++ b/PHASE_EXTERNAL_CONNECTION_REST_API_ENHANCEMENT_DONE.md @@ -0,0 +1,213 @@ +# REST API 연결 관리 기능 구현 완료 + +## 구현 개요 + +외부 커넥션 관리 페이지(`/admin/external-connections`)에 REST API 연결 관리 기능이 추가되었습니다. +기존의 데이터베이스 연결 관리와 함께 REST API 연결도 관리할 수 있도록 탭 기반 UI가 구현되었습니다. + +## 구현 완료 사항 + +### 1. 데이터베이스 (✅ 완료) + +**파일**: `/db/create_external_rest_api_connections.sql` + +- `external_rest_api_connections` 테이블 생성 +- 연결 정보, 인증 설정, 테스트 결과 저장 +- JSONB 타입으로 헤더 및 인증 설정 유연하게 관리 +- 인덱스 최적화 (company_code, is_active, auth_type, JSONB GIN 인덱스) + +**실행 방법**: + +```bash +# PostgreSQL 컨테이너에 접속하여 SQL 실행 +docker exec -i esgrin-mes-db psql -U postgres -d ilshin < db/create_external_rest_api_connections.sql +``` + +### 2. 백엔드 구현 (✅ 완료) + +#### 2.1 타입 정의 + +**파일**: `backend-node/src/types/externalRestApiTypes.ts` + +- `ExternalRestApiConnection`: REST API 연결 정보 인터페이스 +- `RestApiTestRequest`: 연결 테스트 요청 인터페이스 +- `RestApiTestResult`: 테스트 결과 인터페이스 +- `AuthType`: 인증 타입 (none, api-key, bearer, basic, oauth2) +- 각 인증 타입별 세부 설정 인터페이스 + +#### 2.2 서비스 레이어 + +**파일**: `backend-node/src/services/externalRestApiConnectionService.ts` + +- CRUD 작업 구현 (생성, 조회, 수정, 삭제) +- 민감 정보 암호화/복호화 (AES-256-GCM) +- REST API 연결 테스트 기능 +- 필터링 및 검색 기능 +- 유효성 검증 + +#### 2.3 API 라우트 + +**파일**: `backend-node/src/routes/externalRestApiConnectionRoutes.ts` + +- `GET /api/external-rest-api-connections` - 목록 조회 +- `GET /api/external-rest-api-connections/:id` - 상세 조회 +- `POST /api/external-rest-api-connections` - 생성 +- `PUT /api/external-rest-api-connections/:id` - 수정 +- `DELETE /api/external-rest-api-connections/:id` - 삭제 +- `POST /api/external-rest-api-connections/test` - 연결 테스트 +- `POST /api/external-rest-api-connections/:id/test` - ID 기반 테스트 + +#### 2.4 앱 통합 + +**파일**: `backend-node/src/app.ts` + +- 새로운 라우트 등록 완료 + +### 3. 프론트엔드 구현 (✅ 완료) + +#### 3.1 API 클라이언트 + +**파일**: `frontend/lib/api/externalRestApiConnection.ts` + +- 백엔드 API와 통신하는 클라이언트 구현 +- 타입 안전한 API 호출 +- 에러 처리 + +#### 3.2 공통 컴포넌트 + +**파일**: `frontend/components/admin/HeadersManager.tsx` + +- HTTP 헤더 key-value 관리 컴포넌트 +- 동적 추가/삭제 기능 + +**파일**: `frontend/components/admin/AuthenticationConfig.tsx` + +- 인증 타입별 설정 컴포넌트 +- 5가지 인증 방식 지원 (none, api-key, bearer, basic, oauth2) + +#### 3.3 모달 컴포넌트 + +**파일**: `frontend/components/admin/RestApiConnectionModal.tsx` + +- 연결 추가/수정 모달 +- 헤더 관리 및 인증 설정 통합 +- 연결 테스트 기능 + +#### 3.4 목록 관리 컴포넌트 + +**파일**: `frontend/components/admin/RestApiConnectionList.tsx` + +- REST API 연결 목록 표시 +- 검색 및 필터링 +- CRUD 작업 +- 연결 테스트 + +#### 3.5 메인 페이지 + +**파일**: `frontend/app/(main)/admin/external-connections/page.tsx` + +- 탭 기반 UI 구현 (데이터베이스 ↔ REST API) +- 기존 DB 연결 관리와 통합 + +## 주요 기능 + +### 1. 연결 관리 + +- REST API 연결 정보 생성/수정/삭제 +- 연결명, 설명, Base URL 관리 +- Timeout, Retry 설정 +- 활성화 상태 관리 + +### 2. 인증 관리 + +- **None**: 인증 없음 +- **API Key**: 헤더 또는 쿼리 파라미터 +- **Bearer Token**: Authorization: Bearer {token} +- **Basic Auth**: username/password +- **OAuth2**: client_id, client_secret, token_url 등 + +### 3. 헤더 관리 + +- 기본 HTTP 헤더 설정 +- Key-Value 형식으로 동적 관리 +- Content-Type, Accept 등 자유롭게 설정 + +### 4. 연결 테스트 + +- 실시간 연결 테스트 +- HTTP 응답 상태 코드 확인 +- 응답 시간 측정 +- 테스트 결과 저장 + +### 5. 보안 + +- 민감 정보 자동 암호화 (AES-256-GCM) + - API Key + - Bearer Token + - 비밀번호 + - OAuth2 Client Secret +- 암호화된 데이터는 데이터베이스에 안전하게 저장 + +## 사용 방법 + +### 1. SQL 스크립트 실행 + +```bash +# PostgreSQL 컨테이너에 접속 +docker exec -it esgrin-mes-db psql -U postgres -d ilshin + +# 또는 파일 직접 실행 +docker exec -i esgrin-mes-db psql -U postgres -d ilshin < db/create_external_rest_api_connections.sql +``` + +### 2. 백엔드 재시작 + +백엔드 서버가 자동으로 새로운 라우트를 인식합니다. (이미 재시작 완료) + +### 3. 웹 UI 접속 + +1. `/admin/external-connections` 페이지 접속 +2. "REST API 연결" 탭 선택 +3. "새 연결 추가" 버튼 클릭 +4. 필요한 정보 입력 + - 연결명, 설명, Base URL + - 기본 헤더 설정 + - 인증 타입 선택 및 인증 정보 입력 + - Timeout, Retry 설정 +5. "연결 테스트" 버튼으로 즉시 테스트 가능 +6. 저장 + +### 4. 연결 관리 + +- **목록 조회**: 모든 REST API 연결 정보 확인 +- **검색**: 연결명, 설명, URL로 검색 +- **필터링**: 인증 타입, 활성화 상태로 필터링 +- **수정**: 연필 아이콘 클릭하여 수정 +- **삭제**: 휴지통 아이콘 클릭하여 삭제 +- **테스트**: Play 아이콘 클릭하여 연결 테스트 + +## 기술 스택 + +- **Backend**: Node.js, Express, TypeScript, PostgreSQL +- **Frontend**: Next.js, React, TypeScript, Shadcn UI +- **보안**: AES-256-GCM 암호화 +- **데이터**: JSONB (PostgreSQL) + +## 테스트 완료 + +- ✅ 백엔드 컴파일 성공 +- ✅ 서버 정상 실행 확인 +- ✅ 타입 에러 수정 완료 +- ✅ 모든 라우트 등록 완료 +- ✅ 인증 토큰 자동 포함 구현 (apiClient 사용) + +## 다음 단계 + +1. SQL 스크립트 실행 +2. 프론트엔드 빌드 및 테스트 +3. UI에서 연결 추가/수정/삭제/테스트 기능 확인 + +## 참고 문서 + +- 전체 계획: `PHASE_EXTERNAL_CONNECTION_REST_API_ENHANCEMENT.md` +- 기존 외부 DB 연결: `제어관리_외부커넥션_통합_기능_가이드.md` diff --git a/backend-node/data/mail-sent/12b583c9-a6b2-4c7f-8340-fd0e700aa32e.json b/backend-node/data/mail-sent/12b583c9-a6b2-4c7f-8340-fd0e700aa32e.json new file mode 100644 index 00000000..9e7a209c --- /dev/null +++ b/backend-node/data/mail-sent/12b583c9-a6b2-4c7f-8340-fd0e700aa32e.json @@ -0,0 +1,19 @@ +{ + "id": "12b583c9-a6b2-4c7f-8340-fd0e700aa32e", + "sentAt": "2025-10-22T05:17:38.303Z", + "accountId": "account-1759310844272", + "accountName": "이희진", + "accountEmail": "hjlee@wace.me", + "to": [ + "zian9227@naver.com" + ], + "subject": "Fwd: ㅏㅣ", + "htmlContent": "\r\n
\r\n

ㄴㅇㄹㄴㅇㄹㄴㅇㄹㅇ리'ㅐㅔ'ㅑ678463ㅎㄱ휼췇흍츄

\r\n
\r\n

\r\n
\r\n

---------- 전달된 메시지 ----------

\r\n

보낸 사람: \"이희진\"

\r\n

날짜: 2025. 10. 22. 오후 1:32:34

\r\n

제목: ㅏㅣ

\r\n
\r\n undefined\r\n
\r\n ", + "status": "success", + "messageId": "<74dbd467-6185-024d-dd60-bf4459ff9ea4@wace.me>", + "accepted": [ + "zian9227@naver.com" + ], + "rejected": [], + "deletedAt": "2025-10-22T06:36:10.876Z" +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/1bb5ebfe-3f6c-4884-a043-161ae3f74f75.json b/backend-node/data/mail-sent/1bb5ebfe-3f6c-4884-a043-161ae3f74f75.json new file mode 100644 index 00000000..2f624e9c --- /dev/null +++ b/backend-node/data/mail-sent/1bb5ebfe-3f6c-4884-a043-161ae3f74f75.json @@ -0,0 +1,16 @@ +{ + "id": "1bb5ebfe-3f6c-4884-a043-161ae3f74f75", + "accountId": "account-1759310844272", + "accountName": "이희진", + "accountEmail": "hjlee@wace.me", + "to": [], + "cc": [], + "bcc": [], + "subject": "Fwd: ㄴㅇㄹㅇㄴㄴㄹ 테스트트트", + "htmlContent": "\n\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n전달된 메일:\n\n보낸사람: \"이희진\" \n날짜: 2025. 10. 22. 오후 4:24:54\n제목: ㄴㅇㄹㅇㄴㄴㄹ\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\nㄹㅇㄴㄹㅇㄴㄹㅇㄴ\n", + "sentAt": "2025-10-22T07:49:50.811Z", + "status": "draft", + "isDraft": true, + "updatedAt": "2025-10-22T07:49:50.811Z", + "deletedAt": "2025-10-22T07:50:14.211Z" +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/1d997eeb-3d61-427d-8b54-119d4372b9b3.json b/backend-node/data/mail-sent/1d997eeb-3d61-427d-8b54-119d4372b9b3.json new file mode 100644 index 00000000..683ad20c --- /dev/null +++ b/backend-node/data/mail-sent/1d997eeb-3d61-427d-8b54-119d4372b9b3.json @@ -0,0 +1,18 @@ +{ + "id": "1d997eeb-3d61-427d-8b54-119d4372b9b3", + "sentAt": "2025-10-22T07:13:30.905Z", + "accountId": "account-1759310844272", + "accountName": "이희진", + "accountEmail": "hjlee@wace.me", + "to": [ + "zian9227@naver.com" + ], + "subject": "Fwd: ㄴ", + "htmlContent": "\r\n
\r\n

전달히야야양


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
전달된 메일:

보낸사람: \"이희진\"
날짜: 2025. 10. 22. 오후 12:58:15
제목: ㄴ
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

ㄴㅇㄹㄴㅇㄹㄴㅇㄹ

\r\n
\r\n ", + "status": "success", + "messageId": "", + "accepted": [ + "zian9227@naver.com" + ], + "rejected": [] +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/331d95d6-3a13-4657-bc75-ab0811712eb8.json b/backend-node/data/mail-sent/331d95d6-3a13-4657-bc75-ab0811712eb8.json new file mode 100644 index 00000000..5090fdd2 --- /dev/null +++ b/backend-node/data/mail-sent/331d95d6-3a13-4657-bc75-ab0811712eb8.json @@ -0,0 +1,18 @@ +{ + "id": "331d95d6-3a13-4657-bc75-ab0811712eb8", + "sentAt": "2025-10-22T07:18:18.240Z", + "accountId": "account-1759310844272", + "accountName": "이희진", + "accountEmail": "hjlee@wace.me", + "to": [ + "zian9227@naver.com" + ], + "subject": "ㅁㄴㅇㄹㅁㄴㅇㄹ", + "htmlContent": "\r\n
\r\n

ㅁㄴㅇㄹㅁㄴㅇㄹㄴㅇㄹㄴㅇㄹ

\r\n
\r\n ", + "status": "success", + "messageId": "", + "accepted": [ + "zian9227@naver.com" + ], + "rejected": [] +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/375f2326-ca86-468a-bfc3-2d4c3825577b.json b/backend-node/data/mail-sent/375f2326-ca86-468a-bfc3-2d4c3825577b.json new file mode 100644 index 00000000..c142808d --- /dev/null +++ b/backend-node/data/mail-sent/375f2326-ca86-468a-bfc3-2d4c3825577b.json @@ -0,0 +1,19 @@ +{ + "id": "375f2326-ca86-468a-bfc3-2d4c3825577b", + "sentAt": "2025-10-22T04:57:39.706Z", + "accountId": "account-1759310844272", + "accountName": "이희진", + "accountEmail": "hjlee@wace.me", + "to": [ + "\"이희진\" " + ], + "subject": "Re: ㅏㅣ", + "htmlContent": "\r\n
\r\n

ㅁㄴㅇㄹㅁㅇㄴㄹㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㄴㅁㅇㄹ

\r\n
\r\n

\r\n
\r\n

보낸 사람: \"이희진\"

\r\n

날짜: 2025. 10. 22. 오후 1:32:34

\r\n

제목: ㅏㅣ

\r\n
\r\n undefined\r\n
\r\n ", + "status": "success", + "messageId": "", + "accepted": [ + "zian9227@naver.com" + ], + "rejected": [], + "deletedAt": "2025-10-22T07:11:04.666Z" +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/386e334a-df76-440c-ae8a-9bf06982fdc8.json b/backend-node/data/mail-sent/386e334a-df76-440c-ae8a-9bf06982fdc8.json new file mode 100644 index 00000000..31da5552 --- /dev/null +++ b/backend-node/data/mail-sent/386e334a-df76-440c-ae8a-9bf06982fdc8.json @@ -0,0 +1,16 @@ +{ + "id": "386e334a-df76-440c-ae8a-9bf06982fdc8", + "accountId": "account-1759310844272", + "accountName": "이희진", + "accountEmail": "hjlee@wace.me", + "to": [], + "cc": [], + "bcc": [], + "subject": "Fwd: ㄴ", + "htmlContent": "\n

\n
\n

---------- 전달된 메일 ----------

\n

보낸사람: \"이희진\" <zian9227@naver.com>

\n

날짜: 2025. 10. 22. 오후 12:58:15

\n

제목:

\n
\n

ㄴㅇㄹㄴㅇㄹㄴㅇㄹ\n

\n
\n ", + "sentAt": "2025-10-22T07:04:27.192Z", + "status": "draft", + "isDraft": true, + "updatedAt": "2025-10-22T07:04:57.280Z", + "deletedAt": "2025-10-22T07:50:17.136Z" +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/3d411dc4-69a6-4236-b878-9693dff881be.json b/backend-node/data/mail-sent/3d411dc4-69a6-4236-b878-9693dff881be.json new file mode 100644 index 00000000..aa107de7 --- /dev/null +++ b/backend-node/data/mail-sent/3d411dc4-69a6-4236-b878-9693dff881be.json @@ -0,0 +1,18 @@ +{ + "id": "3d411dc4-69a6-4236-b878-9693dff881be", + "accountId": "account-1759310844272", + "accountName": "이희진", + "accountEmail": "hjlee@wace.me", + "to": [ + "zian9227@naver.com" + ], + "cc": [], + "bcc": [], + "subject": "Re: ㄴ", + "htmlContent": "\n

\n
\n

원본 메일:

\n

보낸사람: \"이희진\"

\n

날짜: 2025. 10. 22. 오후 12:58:15

\n

제목:

\n
\n

undefined

\n
\n ", + "sentAt": "2025-10-22T06:56:51.060Z", + "status": "draft", + "isDraft": true, + "updatedAt": "2025-10-22T06:56:51.060Z", + "deletedAt": "2025-10-22T07:50:22.989Z" +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/3e30a264-8431-44c7-96ef-eed551e66a11.json b/backend-node/data/mail-sent/3e30a264-8431-44c7-96ef-eed551e66a11.json new file mode 100644 index 00000000..d824d67b --- /dev/null +++ b/backend-node/data/mail-sent/3e30a264-8431-44c7-96ef-eed551e66a11.json @@ -0,0 +1,16 @@ +{ + "id": "3e30a264-8431-44c7-96ef-eed551e66a11", + "accountId": "account-1759310844272", + "accountName": "이희진", + "accountEmail": "hjlee@wace.me", + "to": [], + "cc": [], + "bcc": [], + "subject": "Fwd: ㄴ", + "htmlContent": "\n

\n
\n

---------- 전달된 메일 ----------

\n

보낸사람: \"이희진\"

\n

날짜: 2025. 10. 22. 오후 12:58:15

\n

제목:

\n
\n

\n
\n ", + "sentAt": "2025-10-22T06:57:53.335Z", + "status": "draft", + "isDraft": true, + "updatedAt": "2025-10-22T07:00:23.394Z", + "deletedAt": "2025-10-22T07:50:20.510Z" +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/4a32bab5-364e-4037-bb00-31d2905824db.json b/backend-node/data/mail-sent/4a32bab5-364e-4037-bb00-31d2905824db.json new file mode 100644 index 00000000..92de4a0c --- /dev/null +++ b/backend-node/data/mail-sent/4a32bab5-364e-4037-bb00-31d2905824db.json @@ -0,0 +1,16 @@ +{ + "id": "4a32bab5-364e-4037-bb00-31d2905824db", + "accountId": "account-1759310844272", + "accountName": "이희진", + "accountEmail": "hjlee@wace.me", + "to": [], + "cc": [], + "bcc": [], + "subject": "테스트 마지가", + "htmlContent": "ㅁㄴㅇㄹ", + "sentAt": "2025-10-22T07:49:29.948Z", + "status": "draft", + "isDraft": true, + "updatedAt": "2025-10-22T07:49:29.948Z", + "deletedAt": "2025-10-22T07:50:12.374Z" +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/5bfb2acd-023a-4865-a738-2900179db5fb.json b/backend-node/data/mail-sent/5bfb2acd-023a-4865-a738-2900179db5fb.json new file mode 100644 index 00000000..5f5a5cfc --- /dev/null +++ b/backend-node/data/mail-sent/5bfb2acd-023a-4865-a738-2900179db5fb.json @@ -0,0 +1,16 @@ +{ + "id": "5bfb2acd-023a-4865-a738-2900179db5fb", + "accountId": "account-1759310844272", + "accountName": "이희진", + "accountEmail": "hjlee@wace.me", + "to": [], + "cc": [], + "bcc": [], + "subject": "Fwd: ㄴ", + "htmlContent": "\n

\n
\n

---------- 전달된 메일 ----------

\n

보낸사람: \"이희진\"

\n

날짜: 2025. 10. 22. 오후 12:58:15

\n

제목:

\n
\n

ㄴㅇㄹㄴㅇㄹㄴㅇㄹ\n

\n
\n ", + "sentAt": "2025-10-22T07:03:09.080Z", + "status": "draft", + "isDraft": true, + "updatedAt": "2025-10-22T07:03:39.150Z", + "deletedAt": "2025-10-22T07:50:19.035Z" +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/683c1323-1895-403a-bb9a-4e111a8909f6.json b/backend-node/data/mail-sent/683c1323-1895-403a-bb9a-4e111a8909f6.json new file mode 100644 index 00000000..b3c3259f --- /dev/null +++ b/backend-node/data/mail-sent/683c1323-1895-403a-bb9a-4e111a8909f6.json @@ -0,0 +1,18 @@ +{ + "id": "683c1323-1895-403a-bb9a-4e111a8909f6", + "accountId": "account-1759310844272", + "accountName": "이희진", + "accountEmail": "hjlee@wace.me", + "to": [ + "zian9227@naver.com" + ], + "cc": [], + "bcc": [], + "subject": "Re: ㄴ", + "htmlContent": "\n

\n
\n

원본 메일:

\n

보낸사람: \"이희진\"

\n

날짜: 2025. 10. 22. 오후 12:58:15

\n

제목:

\n
\n

undefined

\n
\n ", + "sentAt": "2025-10-22T06:54:55.097Z", + "status": "draft", + "isDraft": true, + "updatedAt": "2025-10-22T06:54:55.097Z", + "deletedAt": "2025-10-22T07:50:24.672Z" +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/7bed27d5-dae4-4ba8-85d0-c474c4fb907a.json b/backend-node/data/mail-sent/7bed27d5-dae4-4ba8-85d0-c474c4fb907a.json new file mode 100644 index 00000000..d9edbdeb --- /dev/null +++ b/backend-node/data/mail-sent/7bed27d5-dae4-4ba8-85d0-c474c4fb907a.json @@ -0,0 +1,16 @@ +{ + "id": "7bed27d5-dae4-4ba8-85d0-c474c4fb907a", + "accountId": "account-1759310844272", + "accountName": "이희진", + "accountEmail": "hjlee@wace.me", + "to": [], + "cc": [], + "bcc": [], + "subject": "Fwd: ㅏㅣ", + "htmlContent": "\n

\n
\n

---------- 전달된 메일 ----------

\n

보낸사람: \"이희진\"

\n

날짜: 2025. 10. 22. 오후 1:32:34

\n

제목: ㅏㅣ

\n
\n undefined\n
\n ", + "sentAt": "2025-10-22T06:41:52.984Z", + "status": "draft", + "isDraft": true, + "updatedAt": "2025-10-22T06:46:23.051Z", + "deletedAt": "2025-10-22T07:50:29.124Z" +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/84ee9619-49ff-4f61-a7fa-0bb0b0b7199a.json b/backend-node/data/mail-sent/84ee9619-49ff-4f61-a7fa-0bb0b0b7199a.json new file mode 100644 index 00000000..37317a6a --- /dev/null +++ b/backend-node/data/mail-sent/84ee9619-49ff-4f61-a7fa-0bb0b0b7199a.json @@ -0,0 +1,18 @@ +{ + "id": "84ee9619-49ff-4f61-a7fa-0bb0b0b7199a", + "sentAt": "2025-10-22T04:27:51.044Z", + "accountId": "account-1759310844272", + "accountName": "이희진", + "accountEmail": "hjlee@wace.me", + "to": [ + "\"이희진\" " + ], + "subject": "Re: ㅅㄷㄴㅅ", + "htmlContent": "\r\n
\r\n

야야야야야야야야ㅑㅇ야ㅑㅇ

\r\n
\r\n

\r\n
\r\n

보낸 사람: \"이희진\"

\r\n

날짜: 2025. 10. 22. 오후 1:03:03

\r\n

제목: ㅅㄷㄴㅅ

\r\n
\r\n undefined\r\n
\r\n ", + "status": "success", + "messageId": "<5fa451ff-7d29-7da4-ce56-ca7391c147af@wace.me>", + "accepted": [ + "zian9227@naver.com" + ], + "rejected": [] +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/8990ea86-3112-4e7c-b3e0-8b494181c4e0.json b/backend-node/data/mail-sent/8990ea86-3112-4e7c-b3e0-8b494181c4e0.json new file mode 100644 index 00000000..f0ed2dcf --- /dev/null +++ b/backend-node/data/mail-sent/8990ea86-3112-4e7c-b3e0-8b494181c4e0.json @@ -0,0 +1,13 @@ +{ + "id": "8990ea86-3112-4e7c-b3e0-8b494181c4e0", + "accountName": "", + "accountEmail": "", + "to": [], + "subject": "", + "htmlContent": "", + "sentAt": "2025-10-22T06:17:31.379Z", + "status": "draft", + "isDraft": true, + "updatedAt": "2025-10-22T06:17:31.379Z", + "deletedAt": "2025-10-22T07:50:30.736Z" +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/89a32ace-f39b-44fa-b614-c65d96548f92.json b/backend-node/data/mail-sent/89a32ace-f39b-44fa-b614-c65d96548f92.json new file mode 100644 index 00000000..4ac647c7 --- /dev/null +++ b/backend-node/data/mail-sent/89a32ace-f39b-44fa-b614-c65d96548f92.json @@ -0,0 +1,18 @@ +{ + "id": "89a32ace-f39b-44fa-b614-c65d96548f92", + "sentAt": "2025-10-22T03:49:48.461Z", + "accountId": "account-1759310844272", + "accountName": "이희진", + "accountEmail": "hjlee@wace.me", + "to": [ + "zian9227@naver.com" + ], + "subject": "Fwd: 기상청 API허브 회원가입 인증번호", + "htmlContent": "\r\n
\r\n






---------- 전달된 메시지 ----------


보낸 사람: \"기상청 API허브\"


날짜: 2025. 10. 13. 오후 4:26:45


제목: 기상청 API허브 회원가입 인증번호




undefined

\r\n
\r\n ", + "status": "success", + "messageId": "<9b36ce56-4ef1-cf0c-1f39-2c73bcb521da@wace.me>", + "accepted": [ + "zian9227@naver.com" + ], + "rejected": [] +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/99703f2c-740c-492e-a866-a04289a9b699.json b/backend-node/data/mail-sent/99703f2c-740c-492e-a866-a04289a9b699.json new file mode 100644 index 00000000..1c6dc41f --- /dev/null +++ b/backend-node/data/mail-sent/99703f2c-740c-492e-a866-a04289a9b699.json @@ -0,0 +1,13 @@ +{ + "id": "99703f2c-740c-492e-a866-a04289a9b699", + "accountName": "", + "accountEmail": "", + "to": [], + "subject": "", + "htmlContent": "", + "sentAt": "2025-10-22T06:20:08.450Z", + "status": "draft", + "isDraft": true, + "updatedAt": "2025-10-22T06:20:08.450Z", + "deletedAt": "2025-10-22T06:36:07.797Z" +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/9ab1e5ee-4f5e-4b79-9769-5e2a1e1ffc8e.json b/backend-node/data/mail-sent/9ab1e5ee-4f5e-4b79-9769-5e2a1e1ffc8e.json new file mode 100644 index 00000000..31bde67a --- /dev/null +++ b/backend-node/data/mail-sent/9ab1e5ee-4f5e-4b79-9769-5e2a1e1ffc8e.json @@ -0,0 +1,19 @@ +{ + "id": "9ab1e5ee-4f5e-4b79-9769-5e2a1e1ffc8e", + "sentAt": "2025-10-22T04:31:17.175Z", + "accountId": "account-1759310844272", + "accountName": "이희진", + "accountEmail": "hjlee@wace.me", + "to": [ + "\"이희진\" " + ], + "subject": "Re: ㅅㄷㄴㅅ", + "htmlContent": "\r\n
\r\n

배불르고 졸린데 커피먹으니깐 졸린건 괜찮아졋고 배불러서 물배찼당아아아아

\r\n
\r\n

\r\n
\r\n

보낸 사람: \"이희진\"

\r\n

날짜: 2025. 10. 22. 오후 1:03:03

\r\n

제목: ㅅㄷㄴㅅ

\r\n
\r\n undefined\r\n
\r\n ", + "status": "success", + "messageId": "<0f215ba8-a1e4-8c5a-f43f-962f0717c161@wace.me>", + "accepted": [ + "zian9227@naver.com" + ], + "rejected": [], + "deletedAt": "2025-10-22T07:11:10.245Z" +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/9d0b9fcf-cabf-4053-b6b6-6e110add22de.json b/backend-node/data/mail-sent/9d0b9fcf-cabf-4053-b6b6-6e110add22de.json new file mode 100644 index 00000000..2ace7d67 --- /dev/null +++ b/backend-node/data/mail-sent/9d0b9fcf-cabf-4053-b6b6-6e110add22de.json @@ -0,0 +1,18 @@ +{ + "id": "9d0b9fcf-cabf-4053-b6b6-6e110add22de", + "accountId": "account-1759310844272", + "accountName": "이희진", + "accountEmail": "hjlee@wace.me", + "to": [ + "zian9227@naver.com" + ], + "cc": [], + "bcc": [], + "subject": "Re: ㅏㅣ", + "htmlContent": "\n

\n
\n

원본 메일:

\n

보낸사람: \"이희진\"

\n

날짜: 2025. 10. 22. 오후 1:32:34

\n

제목: ㅏㅣ

\n
\n

undefined

\n
\n ", + "sentAt": "2025-10-22T06:50:04.224Z", + "status": "draft", + "isDraft": true, + "updatedAt": "2025-10-22T06:50:04.224Z", + "deletedAt": "2025-10-22T07:50:26.224Z" +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/a638f7d0-ee31-47fa-9f72-de66ef31ea44.json b/backend-node/data/mail-sent/a638f7d0-ee31-47fa-9f72-de66ef31ea44.json new file mode 100644 index 00000000..5cf165c3 --- /dev/null +++ b/backend-node/data/mail-sent/a638f7d0-ee31-47fa-9f72-de66ef31ea44.json @@ -0,0 +1,18 @@ +{ + "id": "a638f7d0-ee31-47fa-9f72-de66ef31ea44", + "sentAt": "2025-10-22T07:21:13.723Z", + "accountId": "account-1759310844272", + "accountName": "이희진", + "accountEmail": "hjlee@wace.me", + "to": [ + "zian9227@naver.com" + ], + "subject": "ㄹㅇㄴㅁㄹㅇㄴㅁ", + "htmlContent": "\r\n
\r\n

ㄹㅇㄴㅁㄹㅇㄴㅁㅇㄹㅇㄴㅁ

\r\n
\r\n ", + "status": "success", + "messageId": "<5ea07d02-78bf-a655-8289-bcbd8eaf7741@wace.me>", + "accepted": [ + "zian9227@naver.com" + ], + "rejected": [] +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/b293e530-2b2d-4b8a-8081-d103fab5a13f.json b/backend-node/data/mail-sent/b293e530-2b2d-4b8a-8081-d103fab5a13f.json new file mode 100644 index 00000000..77d9053f --- /dev/null +++ b/backend-node/data/mail-sent/b293e530-2b2d-4b8a-8081-d103fab5a13f.json @@ -0,0 +1,18 @@ +{ + "id": "b293e530-2b2d-4b8a-8081-d103fab5a13f", + "accountId": "account-1759310844272", + "accountName": "이희진", + "accountEmail": "hjlee@wace.me", + "to": [ + "zian9227@naver.com" + ], + "cc": [], + "bcc": [], + "subject": "Re: 수신메일확인용", + "htmlContent": "\n

\n
\n

원본 메일:

\n

보낸사람: \"이희진\"

\n

날짜: 2025. 10. 13. 오전 10:40:30

\n

제목: 수신메일확인용

\n
\n undefined\n
\n ", + "sentAt": "2025-10-22T06:47:53.815Z", + "status": "draft", + "isDraft": true, + "updatedAt": "2025-10-22T06:48:53.876Z", + "deletedAt": "2025-10-22T07:50:27.706Z" +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/cf892a77-1998-4165-bb9d-b390451465b2.json b/backend-node/data/mail-sent/cf892a77-1998-4165-bb9d-b390451465b2.json new file mode 100644 index 00000000..426f81fb --- /dev/null +++ b/backend-node/data/mail-sent/cf892a77-1998-4165-bb9d-b390451465b2.json @@ -0,0 +1,16 @@ +{ + "id": "cf892a77-1998-4165-bb9d-b390451465b2", + "accountId": "account-1759310844272", + "accountName": "이희진", + "accountEmail": "hjlee@wace.me", + "to": [], + "cc": [], + "bcc": [], + "subject": "Fwd: ㄴ", + "htmlContent": "\n\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n전달된 메일:\n\n보낸사람: \"이희진\" \n날짜: 2025. 10. 22. 오후 12:58:15\n제목: ㄴ\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\nㄴㅇㄹㄴㅇㄹㄴㅇㄹ\n", + "sentAt": "2025-10-22T07:06:11.620Z", + "status": "draft", + "isDraft": true, + "updatedAt": "2025-10-22T07:07:11.749Z", + "deletedAt": "2025-10-22T07:50:15.739Z" +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/e3501abc-cd31-4b20-bb02-3c7ddbe54eb8.json b/backend-node/data/mail-sent/e3501abc-cd31-4b20-bb02-3c7ddbe54eb8.json new file mode 100644 index 00000000..cf31f7dc --- /dev/null +++ b/backend-node/data/mail-sent/e3501abc-cd31-4b20-bb02-3c7ddbe54eb8.json @@ -0,0 +1,13 @@ +{ + "id": "e3501abc-cd31-4b20-bb02-3c7ddbe54eb8", + "accountName": "", + "accountEmail": "", + "to": [], + "subject": "", + "htmlContent": "", + "sentAt": "2025-10-22T06:15:02.128Z", + "status": "draft", + "isDraft": true, + "updatedAt": "2025-10-22T06:15:02.128Z", + "deletedAt": "2025-10-22T07:08:43.543Z" +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/e93848a8-6901-44c4-b4db-27c8d2aeb8dd.json b/backend-node/data/mail-sent/e93848a8-6901-44c4-b4db-27c8d2aeb8dd.json new file mode 100644 index 00000000..74c8212f --- /dev/null +++ b/backend-node/data/mail-sent/e93848a8-6901-44c4-b4db-27c8d2aeb8dd.json @@ -0,0 +1,27 @@ +{ + "id": "e93848a8-6901-44c4-b4db-27c8d2aeb8dd", + "sentAt": "2025-10-22T04:28:42.686Z", + "accountId": "account-1759310844272", + "accountName": "이희진", + "accountEmail": "hjlee@wace.me", + "to": [ + "\"권은아\" " + ], + "subject": "Re: 매우 졸린 오후예요", + "htmlContent": "\r\n
\r\n

호홋 답장 기능을 구현했다죵
얼른 퇴근하고 싪네여

\r\n
\r\n

\r\n
\r\n

보낸 사람: \"권은아\"

\r\n

날짜: 2025. 10. 22. 오후 1:10:37

\r\n

제목: 매우 졸린 오후예요

\r\n
\r\n undefined\r\n
\r\n ", + "attachments": [ + { + "filename": "test용 이미지2.png", + "originalName": "test용 이미지2.png", + "size": 0, + "path": "/app/uploads/mail-attachments/1761107318152-717716316.png", + "mimetype": "image/png" + } + ], + "status": "success", + "messageId": "<19981423-259b-0a50-e76d-23c860692c16@wace.me>", + "accepted": [ + "chna8137s@gmail.com" + ], + "rejected": [] +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/eb92ed00-cc4f-4cc8-94c9-9bef312d16db.json b/backend-node/data/mail-sent/eb92ed00-cc4f-4cc8-94c9-9bef312d16db.json new file mode 100644 index 00000000..0c19dc0c --- /dev/null +++ b/backend-node/data/mail-sent/eb92ed00-cc4f-4cc8-94c9-9bef312d16db.json @@ -0,0 +1,16 @@ +{ + "id": "eb92ed00-cc4f-4cc8-94c9-9bef312d16db", + "accountId": "account-1759310844272", + "accountName": "이희진", + "accountEmail": "hjlee@wace.me", + "to": [], + "cc": [], + "bcc": [], + "subject": "메일 임시저장 테스트 4", + "htmlContent": "asd", + "sentAt": "2025-10-22T06:21:40.019Z", + "status": "draft", + "isDraft": true, + "updatedAt": "2025-10-22T06:21:40.019Z", + "deletedAt": "2025-10-22T06:36:05.306Z" +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/fcea6149-a098-4212-aa00-baef0cc083d6.json b/backend-node/data/mail-sent/fcea6149-a098-4212-aa00-baef0cc083d6.json new file mode 100644 index 00000000..efd9a0c0 --- /dev/null +++ b/backend-node/data/mail-sent/fcea6149-a098-4212-aa00-baef0cc083d6.json @@ -0,0 +1,18 @@ +{ + "id": "fcea6149-a098-4212-aa00-baef0cc083d6", + "sentAt": "2025-10-22T04:24:54.126Z", + "accountId": "account-1759310844272", + "accountName": "이희진", + "accountEmail": "hjlee@wace.me", + "to": [ + "\"DHS\" " + ], + "subject": "Re: 안녕하세여", + "htmlContent": "\r\n
\r\n

어떻게 가는지 궁금한데 이따가 화면 보여주세영

\r\n
\r\n

\r\n
\r\n

보낸 사람: \"DHS\"

\r\n

날짜: 2025. 10. 22. 오후 1:09:49

\r\n

제목: 안녕하세여

\r\n
\r\n undefined\r\n
\r\n ", + "status": "success", + "messageId": "", + "accepted": [ + "ddhhss0603@gmail.com" + ], + "rejected": [] +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/fd2a8b41-2e6e-4e5e-b8e8-63d31efc5082.json b/backend-node/data/mail-sent/fd2a8b41-2e6e-4e5e-b8e8-63d31efc5082.json new file mode 100644 index 00000000..073c20f0 --- /dev/null +++ b/backend-node/data/mail-sent/fd2a8b41-2e6e-4e5e-b8e8-63d31efc5082.json @@ -0,0 +1,28 @@ +{ + "id": "fd2a8b41-2e6e-4e5e-b8e8-63d31efc5082", + "sentAt": "2025-10-22T04:29:14.738Z", + "accountId": "account-1759310844272", + "accountName": "이희진", + "accountEmail": "hjlee@wace.me", + "to": [ + "\"이희진\" " + ], + "subject": "Re: ㅅㄷㄴㅅ", + "htmlContent": "\r\n
\r\n

ㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㄴㅇㄹㄴㅇㄹ

\r\n
\r\n

\r\n
\r\n

보낸 사람: \"이희진\"

\r\n

날짜: 2025. 10. 22. 오후 1:03:03

\r\n

제목: ㅅㄷㄴㅅ

\r\n
\r\n undefined\r\n
\r\n ", + "attachments": [ + { + "filename": "test용 이미지2.png", + "originalName": "test용 이미지2.png", + "size": 0, + "path": "/app/uploads/mail-attachments/1761107350246-298369766.png", + "mimetype": "image/png" + } + ], + "status": "success", + "messageId": "", + "accepted": [ + "zian9227@naver.com" + ], + "rejected": [], + "deletedAt": "2025-10-22T07:11:12.907Z" +} \ No newline at end of file diff --git a/backend-node/data/todos/todos.json b/backend-node/data/todos/todos.json index e10d42af..5274d604 100644 --- a/backend-node/data/todos/todos.json +++ b/backend-node/data/todos/todos.json @@ -1,55 +1,80 @@ [ - { - "id": "e5bb334c-d58a-4068-ad77-2607a41f4675", - "title": "ㅁㄴㅇㄹ", - "description": "ㅁㄴㅇㄹ", - "priority": "normal", - "status": "completed", - "assignedTo": "", - "dueDate": "2025-10-20T18:17", - "createdAt": "2025-10-20T06:15:49.610Z", - "updatedAt": "2025-10-20T07:36:06.370Z", - "isUrgent": false, - "order": 0, - "completedAt": "2025-10-20T07:36:06.370Z" - }, - { - "id": "334be17c-7776-47e8-89ec-4b57c4a34bcd", - "title": "연동되어주겠니?", - "description": "", - "priority": "normal", - "status": "pending", - "assignedTo": "", - "dueDate": "", - "createdAt": "2025-10-20T06:20:06.343Z", - "updatedAt": "2025-10-20T06:20:06.343Z", - "isUrgent": false, - "order": 1 - }, - { - "id": "f85b81de-fcbd-4858-8973-247d9d6e70ed", - "title": "연동되어주겠니?11", - "description": "ㄴㅇㄹ", - "priority": "normal", - "status": "pending", - "assignedTo": "", - "dueDate": "2025-10-20T17:22", - "createdAt": "2025-10-20T06:20:53.818Z", - "updatedAt": "2025-10-20T06:20:53.818Z", - "isUrgent": false, - "order": 2 - }, { "id": "58d2b26f-5197-4df1-b5d4-724a72ee1d05", "title": "연동되어주려무니", "description": "ㅁㄴㅇㄹ", "priority": "normal", - "status": "pending", + "status": "in_progress", "assignedTo": "", "dueDate": "2025-10-21T15:21", "createdAt": "2025-10-20T06:21:19.817Z", - "updatedAt": "2025-10-20T06:21:19.817Z", + "updatedAt": "2025-10-20T09:00:26.948Z", "isUrgent": false, "order": 3 + }, + { + "id": "c8292b4d-bb45-487c-aa29-55b78580b837", + "title": "오늘의 힐일", + "description": "이거 데이터베이스랑 연결하기", + "priority": "normal", + "status": "pending", + "assignedTo": "", + "dueDate": "2025-10-23T14:04", + "createdAt": "2025-10-23T05:04:50.249Z", + "updatedAt": "2025-10-23T05:04:50.249Z", + "isUrgent": false, + "order": 4 + }, + { + "id": "2c7f90a3-947c-4693-8525-7a2a707172c0", + "title": "테스트용 일정", + "description": "ㅁㄴㅇㄹ", + "priority": "low", + "status": "pending", + "assignedTo": "", + "dueDate": "2025-10-16T18:16", + "createdAt": "2025-10-23T05:13:14.076Z", + "updatedAt": "2025-10-23T05:13:14.076Z", + "isUrgent": false, + "order": 5 + }, + { + "id": "499feff6-92c7-45a9-91fa-ca727edf90f2", + "title": "ㅁSdf", + "description": "asdfsdfs", + "priority": "normal", + "status": "pending", + "assignedTo": "", + "dueDate": "", + "createdAt": "2025-10-23T05:15:38.430Z", + "updatedAt": "2025-10-23T05:15:38.430Z", + "isUrgent": false, + "order": 6 + }, + { + "id": "166c3910-9908-457f-8c72-8d0183f12e2f", + "title": "ㅎㄹㅇㄴ", + "description": "ㅎㄹㅇㄴ", + "priority": "normal", + "status": "pending", + "assignedTo": "", + "dueDate": "", + "createdAt": "2025-10-23T05:21:01.515Z", + "updatedAt": "2025-10-23T05:21:01.515Z", + "isUrgent": false, + "order": 7 + }, + { + "id": "bfa9d476-bb98-41d5-9d74-b016be011bba", + "title": "ㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹ", + "description": "ㅁㄴㅇㄹㄴㅇㄹ", + "priority": "normal", + "status": "pending", + "assignedTo": "", + "dueDate": "", + "createdAt": "2025-10-23T05:21:25.781Z", + "updatedAt": "2025-10-23T05:21:25.781Z", + "isUrgent": false, + "order": 8 } ] \ No newline at end of file diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index 46d2fea5..81adfc5c 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -31,6 +31,8 @@ "nodemailer": "^6.10.1", "oracledb": "^6.9.0", "pg": "^8.16.3", + "quill": "^2.0.3", + "react-quill": "^2.0.0", "redis": "^4.6.10", "uuid": "^13.0.0", "winston": "^3.11.0" @@ -3433,6 +3435,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/quill": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@types/quill/-/quill-1.3.10.tgz", + "integrity": "sha512-IhW3fPW+bkt9MLNlycw8u8fWb7oO7W5URC9MfZYHBlA24rex9rs23D5DETChu1zvgVdc5ka64ICjJOgQMr6Shw==", + "license": "MIT", + "dependencies": { + "parchment": "^1.1.2" + } + }, + "node_modules/@types/quill/node_modules/parchment": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz", + "integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==", + "license": "BSD-3-Clause" + }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", @@ -4437,6 +4454,24 @@ "node": ">= 0.8" } }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "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", @@ -4610,6 +4645,15 @@ "node": ">=12" } }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/cluster-key-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", @@ -4944,6 +4988,26 @@ } } }, + "node_modules/deep-equal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz", + "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==", + "license": "MIT", + "dependencies": { + "is-arguments": "^1.1.1", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "regexp.prototype.flags": "^1.5.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4988,6 +5052,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/define-lazy-prop": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", @@ -5000,6 +5081,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -5554,6 +5652,12 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -5689,6 +5793,12 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5696,6 +5806,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "license": "Apache-2.0" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -5997,6 +6113,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/generate-function": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", @@ -6249,6 +6374,18 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -6563,6 +6700,22 @@ "node": ">= 0.10" } }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -6599,6 +6752,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -6701,6 +6870,24 @@ "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", "license": "MIT" }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -7658,6 +7845,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -7670,6 +7875,13 @@ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", "license": "MIT" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lodash.isinteger": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", @@ -8292,6 +8504,31 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -8436,6 +8673,12 @@ "node": ">=6" } }, + "node_modules/parchment": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz", + "integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==", + "license": "BSD-3-Clause" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -8960,6 +9203,35 @@ ], "license": "MIT" }, + "node_modules/quill": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz", + "integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==", + "license": "BSD-3-Clause", + "dependencies": { + "eventemitter3": "^5.0.1", + "lodash-es": "^4.17.21", + "parchment": "^3.0.0", + "quill-delta": "^5.1.0" + }, + "engines": { + "npm": ">=8.2.3" + } + }, + "node_modules/quill-delta": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz", + "integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==", + "license": "MIT", + "dependencies": { + "fast-diff": "^1.3.0", + "lodash.clonedeep": "^4.5.0", + "lodash.isequal": "^4.5.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -9003,6 +9275,67 @@ "dev": true, "license": "MIT" }, + "node_modules/react-quill": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/react-quill/-/react-quill-2.0.0.tgz", + "integrity": "sha512-4qQtv1FtCfLgoD3PXAur5RyxuUbPXQGOHgTlFie3jtxp43mXDtzCKaOgQ3mLyZfi1PUlyjycfivKelFhy13QUg==", + "license": "MIT", + "dependencies": { + "@types/quill": "^1.3.10", + "lodash": "^4.17.4", + "quill": "^1.3.7" + }, + "peerDependencies": { + "react": "^16 || ^17 || ^18", + "react-dom": "^16 || ^17 || ^18" + } + }, + "node_modules/react-quill/node_modules/eventemitter3": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz", + "integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==", + "license": "MIT" + }, + "node_modules/react-quill/node_modules/fast-diff": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz", + "integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==", + "license": "Apache-2.0" + }, + "node_modules/react-quill/node_modules/parchment": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz", + "integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==", + "license": "BSD-3-Clause" + }, + "node_modules/react-quill/node_modules/quill": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/quill/-/quill-1.3.7.tgz", + "integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==", + "license": "BSD-3-Clause", + "dependencies": { + "clone": "^2.1.1", + "deep-equal": "^1.0.1", + "eventemitter3": "^2.0.3", + "extend": "^3.0.2", + "parchment": "^1.1.4", + "quill-delta": "^3.6.2" + } + }, + "node_modules/react-quill/node_modules/quill-delta": { + "version": "3.6.3", + "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz", + "integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==", + "license": "MIT", + "dependencies": { + "deep-equal": "^1.0.1", + "extend": "^3.0.2", + "fast-diff": "1.1.2" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -9054,6 +9387,26 @@ "@redis/time-series": "1.1.0" } }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -9325,6 +9678,38 @@ "node": ">= 0.8.0" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", diff --git a/backend-node/package.json b/backend-node/package.json index a6744ac6..bacd9fb3 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -45,6 +45,8 @@ "nodemailer": "^6.10.1", "oracledb": "^6.9.0", "pg": "^8.16.3", + "quill": "^2.0.3", + "react-quill": "^2.0.0", "redis": "^4.6.10", "uuid": "^13.0.0", "winston": "^3.11.0" diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index c503f548..979d191b 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -31,10 +31,12 @@ import layoutRoutes from "./routes/layoutRoutes"; import mailTemplateFileRoutes from "./routes/mailTemplateFileRoutes"; import mailAccountFileRoutes from "./routes/mailAccountFileRoutes"; import mailSendSimpleRoutes from "./routes/mailSendSimpleRoutes"; +import mailSentHistoryRoutes from "./routes/mailSentHistoryRoutes"; import mailReceiveBasicRoutes from "./routes/mailReceiveBasicRoutes"; import dataRoutes from "./routes/dataRoutes"; import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes"; import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes"; +import externalRestApiConnectionRoutes from "./routes/externalRestApiConnectionRoutes"; import multiConnectionRoutes from "./routes/multiConnectionRoutes"; import screenFileRoutes from "./routes/screenFileRoutes"; //import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes"; @@ -185,11 +187,13 @@ app.use("/api/layouts", layoutRoutes); app.use("/api/mail/accounts", mailAccountFileRoutes); // 파일 기반 계정 app.use("/api/mail/templates-file", mailTemplateFileRoutes); // 파일 기반 템플릿 app.use("/api/mail/send", mailSendSimpleRoutes); // 메일 발송 +app.use("/api/mail/sent", mailSentHistoryRoutes); // 메일 발송 이력 app.use("/api/mail/receive", mailReceiveBasicRoutes); // 메일 수신 app.use("/api/screen", screenStandardRoutes); app.use("/api/data", dataRoutes); app.use("/api/test-button-dataflow", testButtonDataflowRoutes); app.use("/api/external-db-connections", externalDbConnectionRoutes); +app.use("/api/external-rest-api-connections", externalRestApiConnectionRoutes); app.use("/api/multi-connection", multiConnectionRoutes); app.use("/api/screen-files", screenFileRoutes); app.use("/api/batch-configs", batchRoutes); @@ -268,6 +272,28 @@ app.listen(PORT, HOST, async () => { } catch (error) { logger.error(`❌ 리스크/알림 자동 갱신 시작 실패:`, error); } + + // 메일 자동 삭제 (30일 지난 삭제된 메일) - 매일 새벽 2시 실행 + try { + const cron = await import("node-cron"); + const { mailSentHistoryService } = await import( + "./services/mailSentHistoryService" + ); + + cron.schedule("0 2 * * *", async () => { + try { + logger.info("🗑️ 30일 지난 삭제된 메일 자동 삭제 시작..."); + const deletedCount = await mailSentHistoryService.cleanupOldDeletedMails(); + logger.info(`✅ 30일 지난 메일 ${deletedCount}개 자동 삭제 완료`); + } catch (error) { + logger.error("❌ 메일 자동 삭제 실패:", error); + } + }); + + logger.info(`⏰ 메일 자동 삭제 스케줄러가 시작되었습니다. (매일 새벽 2시)`); + } catch (error) { + logger.error(`❌ 메일 자동 삭제 스케줄러 시작 실패:`, error); + } }); export default app; diff --git a/backend-node/src/controllers/DashboardController.ts b/backend-node/src/controllers/DashboardController.ts index 7d710110..48df8c8f 100644 --- a/backend-node/src/controllers/DashboardController.ts +++ b/backend-node/src/controllers/DashboardController.ts @@ -24,6 +24,8 @@ export class DashboardController { ): Promise { try { const userId = req.user?.userId; + const companyCode = req.user?.companyCode; + if (!userId) { res.status(401).json({ success: false, @@ -89,7 +91,8 @@ export class DashboardController { const savedDashboard = await DashboardService.createDashboard( dashboardData, - userId + userId, + companyCode ); // console.log('대시보드 생성 성공:', { id: savedDashboard.id, title: savedDashboard.title }); @@ -121,6 +124,7 @@ export class DashboardController { async getDashboards(req: AuthenticatedRequest, res: Response): Promise { try { const userId = req.user?.userId; + const companyCode = req.user?.companyCode; const query: DashboardListQuery = { page: parseInt(req.query.page as string) || 1, @@ -145,7 +149,11 @@ export class DashboardController { return; } - const result = await DashboardService.getDashboards(query, userId); + const result = await DashboardService.getDashboards( + query, + userId, + companyCode + ); res.json({ success: true, @@ -173,6 +181,7 @@ export class DashboardController { try { const { id } = req.params; const userId = req.user?.userId; + const companyCode = req.user?.companyCode; if (!id) { res.status(400).json({ @@ -182,7 +191,11 @@ export class DashboardController { return; } - const dashboard = await DashboardService.getDashboardById(id, userId); + const dashboard = await DashboardService.getDashboardById( + id, + userId, + companyCode + ); if (!dashboard) { res.status(404).json({ @@ -393,6 +406,8 @@ export class DashboardController { return; } + const companyCode = req.user?.companyCode; + const query: DashboardListQuery = { page: parseInt(req.query.page as string) || 1, limit: Math.min(parseInt(req.query.limit as string) || 20, 100), @@ -401,7 +416,11 @@ export class DashboardController { createdBy: userId, // 본인이 만든 대시보드만 }; - const result = await DashboardService.getDashboards(query, userId); + const result = await DashboardService.getDashboards( + query, + userId, + companyCode + ); res.json({ success: true, @@ -422,7 +441,7 @@ export class DashboardController { } /** - * 쿼리 실행 + * 쿼리 실행 (SELECT만) * POST /api/dashboards/execute-query */ async executeQuery(req: AuthenticatedRequest, res: Response): Promise { @@ -487,6 +506,79 @@ export class DashboardController { } } + /** + * DML 쿼리 실행 (INSERT, UPDATE, DELETE) + * POST /api/dashboards/execute-dml + */ + async executeDML(req: AuthenticatedRequest, res: Response): Promise { + try { + const { query } = req.body; + + // 유효성 검증 + if (!query || typeof query !== "string" || query.trim().length === 0) { + res.status(400).json({ + success: false, + message: "쿼리가 필요합니다.", + }); + return; + } + + // SQL 인젝션 방지를 위한 기본적인 검증 + const trimmedQuery = query.trim().toLowerCase(); + const allowedCommands = ["insert", "update", "delete"]; + const isAllowed = allowedCommands.some((cmd) => + trimmedQuery.startsWith(cmd) + ); + + if (!isAllowed) { + res.status(400).json({ + success: false, + message: "INSERT, UPDATE, DELETE 쿼리만 허용됩니다.", + }); + return; + } + + // 위험한 명령어 차단 + const dangerousPatterns = [ + /drop\s+table/i, + /drop\s+database/i, + /truncate/i, + /alter\s+table/i, + /create\s+table/i, + ]; + + if (dangerousPatterns.some((pattern) => pattern.test(query))) { + res.status(403).json({ + success: false, + message: "허용되지 않는 쿼리입니다.", + }); + return; + } + + // 쿼리 실행 + const result = await PostgreSQLService.query(query.trim()); + + res.status(200).json({ + success: true, + data: { + rowCount: result.rowCount || 0, + command: result.command, + }, + message: "쿼리가 성공적으로 실행되었습니다.", + }); + } catch (error) { + console.error("DML execution error:", error); + res.status(500).json({ + success: false, + message: "쿼리 실행 중 오류가 발생했습니다.", + error: + process.env.NODE_ENV === "development" + ? (error as Error).message + : "쿼리 실행 오류", + }); + } + } + /** * 외부 API 프록시 (CORS 우회용) * POST /api/dashboards/fetch-external-api diff --git a/backend-node/src/controllers/dynamicFormController.ts b/backend-node/src/controllers/dynamicFormController.ts index bc3e6f52..a5b2f225 100644 --- a/backend-node/src/controllers/dynamicFormController.ts +++ b/backend-node/src/controllers/dynamicFormController.ts @@ -36,10 +36,18 @@ export const saveFormData = async ( formDataWithMeta.company_code = companyCode; } + // 클라이언트 IP 주소 추출 + const ipAddress = + req.ip || + (req.headers["x-forwarded-for"] as string) || + req.socket.remoteAddress || + "unknown"; + const result = await dynamicFormService.saveFormData( screenId, tableName, - formDataWithMeta + formDataWithMeta, + ipAddress ); res.json({ diff --git a/backend-node/src/controllers/flowController.ts b/backend-node/src/controllers/flowController.ts index e555e6f7..f596af97 100644 --- a/backend-node/src/controllers/flowController.ts +++ b/backend-node/src/controllers/flowController.ts @@ -31,30 +31,41 @@ export class FlowController { */ createFlowDefinition = async (req: Request, res: Response): Promise => { try { - const { name, description, tableName } = req.body; + const { name, description, tableName, dbSourceType, dbConnectionId } = + req.body; const userId = (req as any).user?.userId || "system"; - if (!name || !tableName) { + console.log("🔍 createFlowDefinition called with:", { + name, + description, + tableName, + dbSourceType, + dbConnectionId, + }); + + if (!name) { res.status(400).json({ success: false, - message: "Name and tableName are required", + message: "Name is required", }); return; } - // 테이블 존재 확인 - const tableExists = - await this.flowDefinitionService.checkTableExists(tableName); - if (!tableExists) { - res.status(400).json({ - success: false, - message: `Table '${tableName}' does not exist`, - }); - return; + // 테이블 이름이 제공된 경우에만 존재 확인 + if (tableName) { + const tableExists = + await this.flowDefinitionService.checkTableExists(tableName); + if (!tableExists) { + res.status(400).json({ + success: false, + message: `Table '${tableName}' does not exist`, + }); + return; + } } const flowDef = await this.flowDefinitionService.create( - { name, description, tableName }, + { name, description, tableName, dbSourceType, dbConnectionId }, userId ); @@ -294,6 +305,13 @@ export class FlowController { color, positionX, positionY, + moveType, + statusColumn, + statusValue, + targetTable, + fieldMappings, + integrationType, + integrationConfig, } = req.body; const step = await this.flowStepService.update(id, { @@ -304,6 +322,13 @@ export class FlowController { color, positionX, positionY, + moveType, + statusColumn, + statusValue, + targetTable, + fieldMappings, + integrationType, + integrationConfig, }); if (!step) { diff --git a/backend-node/src/controllers/mailReceiveBasicController.ts b/backend-node/src/controllers/mailReceiveBasicController.ts index 7722840d..2de79185 100644 --- a/backend-node/src/controllers/mailReceiveBasicController.ts +++ b/backend-node/src/controllers/mailReceiveBasicController.ts @@ -18,11 +18,11 @@ export class MailReceiveBasicController { */ async getMailList(req: Request, res: Response) { try { - console.log('📬 메일 목록 조회 요청:', { - params: req.params, - path: req.path, - originalUrl: req.originalUrl - }); + // console.log('📬 메일 목록 조회 요청:', { + // params: req.params, + // path: req.path, + // originalUrl: req.originalUrl + // }); const { accountId } = req.params; const limit = parseInt(req.query.limit as string) || 50; @@ -49,11 +49,11 @@ export class MailReceiveBasicController { */ async getMailDetail(req: Request, res: Response) { try { - console.log('🔍 메일 상세 조회 요청:', { - params: req.params, - path: req.path, - originalUrl: req.originalUrl - }); + // console.log('🔍 메일 상세 조회 요청:', { + // params: req.params, + // path: req.path, + // originalUrl: req.originalUrl + // }); const { accountId, seqno } = req.params; const seqnoNumber = parseInt(seqno, 10); @@ -121,39 +121,39 @@ export class MailReceiveBasicController { */ async downloadAttachment(req: Request, res: Response) { try { - console.log('📎🎯 컨트롤러 downloadAttachment 진입'); + // console.log('📎🎯 컨트롤러 downloadAttachment 진입'); const { accountId, seqno, index } = req.params; - console.log(`📎 파라미터: accountId=${accountId}, seqno=${seqno}, index=${index}`); + // console.log(`📎 파라미터: accountId=${accountId}, seqno=${seqno}, index=${index}`); const seqnoNumber = parseInt(seqno, 10); const indexNumber = parseInt(index, 10); if (isNaN(seqnoNumber) || isNaN(indexNumber)) { - console.log('❌ 유효하지 않은 파라미터'); + // console.log('❌ 유효하지 않은 파라미터'); return res.status(400).json({ success: false, message: '유효하지 않은 파라미터입니다.', }); } - console.log('📎 서비스 호출 시작...'); + // console.log('📎 서비스 호출 시작...'); const result = await this.mailReceiveService.downloadAttachment( accountId, seqnoNumber, indexNumber ); - console.log(`📎 서비스 호출 완료: result=${result ? '있음' : '없음'}`); + // console.log(`📎 서비스 호출 완료: result=${result ? '있음' : '없음'}`); if (!result) { - console.log('❌ 첨부파일을 찾을 수 없음'); + // console.log('❌ 첨부파일을 찾을 수 없음'); return res.status(404).json({ success: false, message: '첨부파일을 찾을 수 없습니다.', }); } - console.log(`📎 파일 다운로드 시작: ${result.filename}`); - console.log(`📎 파일 경로: ${result.filePath}`); + // console.log(`📎 파일 다운로드 시작: ${result.filename}`); + // console.log(`📎 파일 경로: ${result.filePath}`); // 파일 다운로드 res.download(result.filePath, result.filename, (err) => { @@ -217,5 +217,35 @@ export class MailReceiveBasicController { }); } } + + /** + * DELETE /api/mail/receive/:accountId/:seqno + * IMAP 서버에서 메일 삭제 + */ + async deleteMail(req: Request, res: Response) { + try { + const { accountId, seqno } = req.params; + const seqnoNumber = parseInt(seqno, 10); + + if (isNaN(seqnoNumber)) { + return res.status(400).json({ + success: false, + message: '유효하지 않은 메일 번호입니다.', + }); + } + + const result = await this.mailReceiveService.deleteMail(accountId, seqnoNumber); + + return res.status(200).json(result); + } catch (error: unknown) { + console.error('메일 삭제 실패:', error); + return res.status(500).json({ + success: false, + message: error instanceof Error ? error.message : '메일 삭제 실패', + }); + } + } } + +export const mailReceiveBasicController = new MailReceiveBasicController(); diff --git a/backend-node/src/controllers/mailSendSimpleController.ts b/backend-node/src/controllers/mailSendSimpleController.ts index de8610b7..4736e98c 100644 --- a/backend-node/src/controllers/mailSendSimpleController.ts +++ b/backend-node/src/controllers/mailSendSimpleController.ts @@ -7,14 +7,14 @@ export class MailSendSimpleController { */ async sendMail(req: Request, res: Response) { try { - console.log('📧 메일 발송 요청 수신:', { - accountId: req.body.accountId, - to: req.body.to, - cc: req.body.cc, - bcc: req.body.bcc, - subject: req.body.subject, - attachments: req.files ? (req.files as Express.Multer.File[]).length : 0, - }); + // console.log('📧 메일 발송 요청 수신:', { + // accountId: req.body.accountId, + // to: req.body.to, + // cc: req.body.cc, + // bcc: req.body.bcc, + // subject: req.body.subject, + // attachments: req.files ? (req.files as Express.Multer.File[]).length : 0, + // }); // FormData에서 JSON 문자열 파싱 const accountId = req.body.accountId; @@ -31,7 +31,7 @@ export class MailSendSimpleController { // 필수 파라미터 검증 if (!accountId || !to || !Array.isArray(to) || to.length === 0) { - console.log('❌ 필수 파라미터 누락'); + // console.log('❌ 필수 파라미터 누락'); return res.status(400).json({ success: false, message: '계정 ID와 수신자 이메일이 필요합니다.', @@ -63,9 +63,9 @@ export class MailSendSimpleController { if (req.body.fileNames) { try { parsedFileNames = JSON.parse(req.body.fileNames); - console.log('📎 프론트엔드에서 받은 파일명들:', parsedFileNames); + // console.log('📎 프론트엔드에서 받은 파일명들:', parsedFileNames); } catch (e) { - console.warn('파일명 파싱 실패, multer originalname 사용'); + // console.warn('파일명 파싱 실패, multer originalname 사용'); } } @@ -83,10 +83,10 @@ export class MailSendSimpleController { }); }); - console.log('📎 최종 첨부파일 정보:', attachments.map(a => ({ - filename: a.filename, - path: a.path.split('/').pop() - }))); + // console.log('📎 최종 첨부파일 정보:', attachments.map(a => ({ + // filename: a.filename, + // path: a.path.split('/').pop() + // }))); } // 메일 발송 @@ -125,6 +125,63 @@ export class MailSendSimpleController { } } + /** + * 대량 메일 발송 + */ + async sendBulkMail(req: Request, res: Response) { + try { + const { accountId, templateId, customHtml, subject, recipients } = req.body; + + // 필수 파라미터 검증 + if (!accountId || !subject || !recipients || !Array.isArray(recipients)) { + return res.status(400).json({ + success: false, + message: '필수 파라미터가 누락되었습니다.', + }); + } + + // 템플릿 또는 직접 작성 중 하나는 있어야 함 + if (!templateId && !customHtml) { + return res.status(400).json({ + success: false, + message: '템플릿 또는 메일 내용 중 하나는 필수입니다.', + }); + } + + if (recipients.length === 0) { + return res.status(400).json({ + success: false, + message: '수신자가 없습니다.', + }); + } + + // console.log(`📧 대량 발송 요청: ${recipients.length}명`); + + // 대량 발송 실행 + const result = await mailSendSimpleService.sendBulkMail({ + accountId, + templateId, // 선택 + customHtml, // 선택 + subject, + recipients, + }); + + return res.json({ + success: true, + data: result, + message: `${result.success}/${result.total} 건 발송 완료`, + }); + } catch (error: unknown) { + const err = error as Error; + console.error('❌ 대량 발송 오류:', err); + return res.status(500).json({ + success: false, + message: '대량 발송 중 오류가 발생했습니다.', + error: err.message, + }); + } + } + /** * SMTP 연결 테스트 */ diff --git a/backend-node/src/controllers/mailSentHistoryController.ts b/backend-node/src/controllers/mailSentHistoryController.ts index 129d72a7..5451862f 100644 --- a/backend-node/src/controllers/mailSentHistoryController.ts +++ b/backend-node/src/controllers/mailSentHistoryController.ts @@ -11,12 +11,14 @@ export class MailSentHistoryController { page: req.query.page ? parseInt(req.query.page as string) : undefined, limit: req.query.limit ? parseInt(req.query.limit as string) : undefined, searchTerm: req.query.searchTerm as string | undefined, - status: req.query.status as 'success' | 'failed' | 'all' | undefined, + status: req.query.status as 'success' | 'failed' | 'draft' | 'all' | undefined, accountId: req.query.accountId as string | undefined, startDate: req.query.startDate as string | undefined, endDate: req.query.endDate as string | undefined, - sortBy: req.query.sortBy as 'sentAt' | 'subject' | undefined, + sortBy: req.query.sortBy as 'sentAt' | 'subject' | 'updatedAt' | undefined, sortOrder: req.query.sortOrder as 'asc' | 'desc' | undefined, + includeDeleted: req.query.includeDeleted === 'true', + onlyDeleted: req.query.onlyDeleted === 'true', }; const result = await mailSentHistoryService.getSentMailList(query); @@ -112,6 +114,144 @@ export class MailSentHistoryController { } } + /** + * 임시 저장 (Draft) + */ + async saveDraft(req: Request, res: Response) { + try { + const draft = await mailSentHistoryService.saveDraft(req.body); + + return res.json({ + success: true, + data: draft, + message: '임시 저장되었습니다.', + }); + } catch (error: unknown) { + const err = error as Error; + console.error('임시 저장 실패:', err); + return res.status(500).json({ + success: false, + message: '임시 저장 중 오류가 발생했습니다.', + error: err.message, + }); + } + } + + /** + * 임시 저장 업데이트 + */ + async updateDraft(req: Request, res: Response) { + try { + const { id } = req.params; + + if (!id) { + return res.status(400).json({ + success: false, + message: '임시 저장 ID가 필요합니다.', + }); + } + + const updated = await mailSentHistoryService.updateDraft(id, req.body); + + if (!updated) { + return res.status(404).json({ + success: false, + message: '임시 저장을 찾을 수 없습니다.', + }); + } + + return res.json({ + success: true, + data: updated, + message: '임시 저장이 업데이트되었습니다.', + }); + } catch (error: unknown) { + const err = error as Error; + console.error('임시 저장 업데이트 실패:', err); + return res.status(500).json({ + success: false, + message: '임시 저장 업데이트 중 오류가 발생했습니다.', + error: err.message, + }); + } + } + + /** + * 메일 복구 + */ + async restoreMail(req: Request, res: Response) { + try { + const { id } = req.params; + + if (!id) { + return res.status(400).json({ + success: false, + message: '메일 ID가 필요합니다.', + }); + } + + const success = await mailSentHistoryService.restoreMail(id); + + if (!success) { + return res.status(404).json({ + success: false, + message: '복구할 메일을 찾을 수 없습니다.', + }); + } + + return res.json({ + success: true, + message: '메일이 복구되었습니다.', + }); + } catch (error: unknown) { + const err = error as Error; + console.error('메일 복구 실패:', err); + return res.status(500).json({ + success: false, + message: '메일 복구 중 오류가 발생했습니다.', + error: err.message, + }); + } + } + + /** + * 메일 영구 삭제 + */ + async permanentlyDelete(req: Request, res: Response) { + try { + const { id } = req.params; + + if (!id) { + return res.status(400).json({ + success: false, + message: '메일 ID가 필요합니다.', + }); + } + + const success = await mailSentHistoryService.permanentlyDeleteMail(id); + + if (!success) { + return res.status(404).json({ + success: false, + message: '삭제할 메일을 찾을 수 없습니다.', + }); + } + + return res.json({ + success: true, + message: '메일이 영구 삭제되었습니다.', + }); + } catch (error: unknown) { + const err = error as Error; + console.error('메일 영구 삭제 실패:', err); + return res.status(500).json({ + success: false, + message: '메일 영구 삭제 중 오류가 발생했습니다.', + error: err.message, + }); + } + } + /** * 통계 조회 */ @@ -134,6 +274,117 @@ export class MailSentHistoryController { }); } } + + /** + * 일괄 삭제 + */ + async bulkDelete(req: Request, res: Response) { + try { + const { ids } = req.body; + + if (!ids || !Array.isArray(ids) || ids.length === 0) { + return res.status(400).json({ + success: false, + message: '삭제할 메일 ID 목록이 필요합니다.', + }); + } + + const results = await Promise.allSettled( + ids.map((id: string) => mailSentHistoryService.deleteSentMail(id)) + ); + + const successCount = results.filter((r) => r.status === 'fulfilled' && r.value).length; + const failCount = results.length - successCount; + + return res.json({ + success: true, + message: `${successCount}개 메일 삭제 완료 (실패: ${failCount}개)`, + data: { successCount, failCount }, + }); + } catch (error: unknown) { + const err = error as Error; + console.error('일괄 삭제 실패:', err); + return res.status(500).json({ + success: false, + message: '일괄 삭제 중 오류가 발생했습니다.', + error: err.message, + }); + } + } + + /** + * 일괄 영구 삭제 + */ + async bulkPermanentDelete(req: Request, res: Response) { + try { + const { ids } = req.body; + + if (!ids || !Array.isArray(ids) || ids.length === 0) { + return res.status(400).json({ + success: false, + message: '영구 삭제할 메일 ID 목록이 필요합니다.', + }); + } + + const results = await Promise.allSettled( + ids.map((id: string) => mailSentHistoryService.permanentlyDeleteMail(id)) + ); + + const successCount = results.filter((r) => r.status === 'fulfilled' && r.value).length; + const failCount = results.length - successCount; + + return res.json({ + success: true, + message: `${successCount}개 메일 영구 삭제 완료 (실패: ${failCount}개)`, + data: { successCount, failCount }, + }); + } catch (error: unknown) { + const err = error as Error; + console.error('일괄 영구 삭제 실패:', err); + return res.status(500).json({ + success: false, + message: '일괄 영구 삭제 중 오류가 발생했습니다.', + error: err.message, + }); + } + } + + /** + * 일괄 복구 + */ + async bulkRestore(req: Request, res: Response) { + try { + const { ids } = req.body; + + if (!ids || !Array.isArray(ids) || ids.length === 0) { + return res.status(400).json({ + success: false, + message: '복구할 메일 ID 목록이 필요합니다.', + }); + } + + const results = await Promise.allSettled( + ids.map((id: string) => mailSentHistoryService.restoreMail(id)) + ); + + const successCount = results.filter((r) => r.status === 'fulfilled' && r.value).length; + const failCount = results.length - successCount; + + return res.json({ + success: true, + message: `${successCount}개 메일 복구 완료 (실패: ${failCount}개)`, + data: { successCount, failCount }, + }); + } catch (error: unknown) { + const err = error as Error; + console.error('일괄 복구 실패:', err); + return res.status(500).json({ + success: false, + message: '일괄 복구 중 오류가 발생했습니다.', + error: err.message, + }); + } + } } export const mailSentHistoryController = new MailSentHistoryController(); diff --git a/backend-node/src/controllers/openApiProxyController.ts b/backend-node/src/controllers/openApiProxyController.ts index b84dc218..d7cf570e 100644 --- a/backend-node/src/controllers/openApiProxyController.ts +++ b/backend-node/src/controllers/openApiProxyController.ts @@ -968,9 +968,14 @@ function parseKMADataWeatherData(data: any, gridCoord: { name: string; nx: numbe clouds = 30; } + // 격자좌표 → 위도경도 변환 + const { lat, lng } = gridToLatLng(gridCoord.nx, gridCoord.ny); + return { city: gridCoord.name, country: 'KR', + lat, + lng, temperature: Math.round(temperature), feelsLike: Math.round(temperature - 2), humidity: Math.round(humidity), @@ -1110,6 +1115,65 @@ function getGridCoordinates(city: string): { name: string; nx: number; ny: numbe return grids[city] || null; } +/** + * 격자좌표(nx, ny)를 위도경도로 변환 + * 기상청 격자 → 위경도 변환 공식 사용 + */ +function gridToLatLng(nx: number, ny: number): { lat: number; lng: number } { + const RE = 6371.00877; // 지구 반경(km) + const GRID = 5.0; // 격자 간격(km) + const SLAT1 = 30.0; // 표준위도1(degree) + const SLAT2 = 60.0; // 표준위도2(degree) + const OLON = 126.0; // 기준점 경도(degree) + const OLAT = 38.0; // 기준점 위도(degree) + const XO = 43; // 기준점 X좌표 + const YO = 136; // 기준점 Y좌표 + + const DEGRAD = Math.PI / 180.0; + const re = RE / GRID; + const slat1 = SLAT1 * DEGRAD; + const slat2 = SLAT2 * DEGRAD; + const olon = OLON * DEGRAD; + const olat = OLAT * DEGRAD; + + const sn = Math.tan(Math.PI * 0.25 + slat2 * 0.5) / Math.tan(Math.PI * 0.25 + slat1 * 0.5); + const sn_log = Math.log(Math.cos(slat1) / Math.cos(slat2)) / Math.log(sn); + const sf = Math.tan(Math.PI * 0.25 + slat1 * 0.5); + const sf_pow = Math.pow(sf, sn_log); + const sf_result = (Math.cos(slat1) * sf_pow) / sn_log; + const ro = Math.tan(Math.PI * 0.25 + olat * 0.5); + const ro_pow = Math.pow(ro, sn_log); + const ro_result = (re * sf_result) / ro_pow; + + const xn = nx - XO; + const yn = ro_result - (ny - YO); + const ra = Math.sqrt(xn * xn + yn * yn); + let alat: number; + + if (sn_log > 0) { + alat = 2.0 * Math.atan(Math.pow((re * sf_result) / ra, 1.0 / sn_log)) - Math.PI * 0.5; + } else { + alat = -2.0 * Math.atan(Math.pow((re * sf_result) / ra, 1.0 / sn_log)) + Math.PI * 0.5; + } + + let theta: number; + if (Math.abs(xn) <= 0.0) { + theta = 0.0; + } else { + if (Math.abs(yn) <= 0.0) { + theta = 0.0; + } else { + theta = Math.atan2(xn, yn); + } + } + const alon = theta / sn_log + olon; + + return { + lat: parseFloat((alat / DEGRAD).toFixed(6)), + lng: parseFloat((alon / DEGRAD).toFixed(6)), + }; +} + /** * 공공데이터포털 초단기실황 응답 파싱 * @param apiResponse - 공공데이터포털 API 응답 데이터 @@ -1171,8 +1235,13 @@ function parseDataPortalWeatherData(apiResponse: any, gridInfo: { name: string; weatherDescription = '추움'; } + // 격자좌표 → 위도경도 변환 + const { lat, lng } = gridToLatLng(gridInfo.nx, gridInfo.ny); + return { city: gridInfo.name, + lat, + lng, temperature: Math.round(temperature * 10) / 10, humidity: Math.round(humidity), windSpeed: Math.round(windSpeed * 10) / 10, diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index aac86625..d7b2bd74 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -1048,3 +1048,268 @@ export async function updateColumnWebType( res.status(500).json(response); } } + +// ======================================== +// 🎯 테이블 로그 시스템 API +// ======================================== + +/** + * 로그 테이블 생성 + */ +export async function createLogTable( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const { pkColumn } = req.body; + const userId = req.user?.userId; + + logger.info(`=== 로그 테이블 생성 시작: ${tableName} ===`); + + if (!tableName) { + const response: ApiResponse = { + success: false, + message: "테이블명이 필요합니다.", + error: { + code: "MISSING_TABLE_NAME", + details: "테이블명 파라미터가 누락되었습니다.", + }, + }; + res.status(400).json(response); + return; + } + + if (!pkColumn || !pkColumn.columnName || !pkColumn.dataType) { + const response: ApiResponse = { + success: false, + message: "PK 컬럼 정보가 필요합니다.", + error: { + code: "MISSING_PK_COLUMN", + details: "PK 컬럼명과 데이터 타입이 필요합니다.", + }, + }; + res.status(400).json(response); + return; + } + + const tableManagementService = new TableManagementService(); + await tableManagementService.createLogTable(tableName, pkColumn, userId); + + logger.info(`로그 테이블 생성 완료: ${tableName}_log`); + + const response: ApiResponse = { + success: true, + message: "로그 테이블이 성공적으로 생성되었습니다.", + }; + + res.status(200).json(response); + } catch (error) { + logger.error("로그 테이블 생성 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "로그 테이블 생성 중 오류가 발생했습니다.", + error: { + code: "LOG_TABLE_CREATE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + +/** + * 로그 설정 조회 + */ +export async function getLogConfig( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + + logger.info(`=== 로그 설정 조회: ${tableName} ===`); + + if (!tableName) { + const response: ApiResponse = { + success: false, + message: "테이블명이 필요합니다.", + error: { + code: "MISSING_TABLE_NAME", + details: "테이블명 파라미터가 누락되었습니다.", + }, + }; + res.status(400).json(response); + return; + } + + const tableManagementService = new TableManagementService(); + const logConfig = await tableManagementService.getLogConfig(tableName); + + const response: ApiResponse = { + success: true, + message: "로그 설정을 조회했습니다.", + data: logConfig, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("로그 설정 조회 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "로그 설정 조회 중 오류가 발생했습니다.", + error: { + code: "LOG_CONFIG_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + +/** + * 로그 데이터 조회 + */ +export async function getLogData( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const { + page = 1, + size = 20, + operationType, + startDate, + endDate, + changedBy, + originalId, + } = req.query; + + logger.info(`=== 로그 데이터 조회: ${tableName} ===`); + + if (!tableName) { + const response: ApiResponse = { + success: false, + message: "테이블명이 필요합니다.", + error: { + code: "MISSING_TABLE_NAME", + details: "테이블명 파라미터가 누락되었습니다.", + }, + }; + res.status(400).json(response); + return; + } + + const tableManagementService = new TableManagementService(); + const result = await tableManagementService.getLogData(tableName, { + page: parseInt(page as string), + size: parseInt(size as string), + operationType: operationType as string, + startDate: startDate as string, + endDate: endDate as string, + changedBy: changedBy as string, + originalId: originalId as string, + }); + + logger.info( + `로그 데이터 조회 완료: ${tableName}_log, ${result.total}건` + ); + + const response: ApiResponse = { + success: true, + message: "로그 데이터를 조회했습니다.", + data: result, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("로그 데이터 조회 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "로그 데이터 조회 중 오류가 발생했습니다.", + error: { + code: "LOG_DATA_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + +/** + * 로그 테이블 활성화/비활성화 + */ +export async function toggleLogTable( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const { isActive } = req.body; + + logger.info(`=== 로그 테이블 토글: ${tableName}, isActive: ${isActive} ===`); + + if (!tableName) { + const response: ApiResponse = { + success: false, + message: "테이블명이 필요합니다.", + error: { + code: "MISSING_TABLE_NAME", + details: "테이블명 파라미터가 누락되었습니다.", + }, + }; + res.status(400).json(response); + return; + } + + if (isActive === undefined || isActive === null) { + const response: ApiResponse = { + success: false, + message: "isActive 값이 필요합니다.", + error: { + code: "MISSING_IS_ACTIVE", + details: "isActive 파라미터가 누락되었습니다.", + }, + }; + res.status(400).json(response); + return; + } + + const tableManagementService = new TableManagementService(); + await tableManagementService.toggleLogTable( + tableName, + isActive === "Y" || isActive === true + ); + + logger.info( + `로그 테이블 토글 완료: ${tableName}, isActive: ${isActive}` + ); + + const response: ApiResponse = { + success: true, + message: `로그 기능이 ${isActive ? "활성화" : "비활성화"}되었습니다.`, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("로그 테이블 토글 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "로그 테이블 토글 중 오류가 발생했습니다.", + error: { + code: "LOG_TOGGLE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} diff --git a/backend-node/src/routes/dashboardRoutes.ts b/backend-node/src/routes/dashboardRoutes.ts index 87db696b..2356d05d 100644 --- a/backend-node/src/routes/dashboardRoutes.ts +++ b/backend-node/src/routes/dashboardRoutes.ts @@ -24,12 +24,18 @@ router.get( dashboardController.getDashboard.bind(dashboardController) ); -// 쿼리 실행 (인증 불필요 - 개발용) +// 쿼리 실행 (SELECT만, 인증 불필요 - 개발용) router.post( "/execute-query", dashboardController.executeQuery.bind(dashboardController) ); +// DML 쿼리 실행 (INSERT/UPDATE/DELETE, 인증 불필요 - 개발용) +router.post( + "/execute-dml", + dashboardController.executeDML.bind(dashboardController) +); + // 외부 API 프록시 (CORS 우회) router.post( "/fetch-external-api", diff --git a/backend-node/src/routes/externalRestApiConnectionRoutes.ts b/backend-node/src/routes/externalRestApiConnectionRoutes.ts new file mode 100644 index 00000000..0e2de684 --- /dev/null +++ b/backend-node/src/routes/externalRestApiConnectionRoutes.ts @@ -0,0 +1,252 @@ +import { Router, Request, Response } from "express"; +import { + authenticateToken, + AuthenticatedRequest, +} from "../middleware/authMiddleware"; +import { ExternalRestApiConnectionService } from "../services/externalRestApiConnectionService"; +import { + ExternalRestApiConnection, + ExternalRestApiConnectionFilter, + RestApiTestRequest, +} from "../types/externalRestApiTypes"; +import logger from "../utils/logger"; + +const router = Router(); + +/** + * GET /api/external-rest-api-connections + * REST API 연결 목록 조회 + */ +router.get( + "/", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + try { + const filter: ExternalRestApiConnectionFilter = { + search: req.query.search as string, + auth_type: req.query.auth_type as string, + is_active: req.query.is_active as string, + company_code: req.query.company_code as string, + }; + + const result = + await ExternalRestApiConnectionService.getConnections(filter); + + return res.status(result.success ? 200 : 400).json(result); + } catch (error) { + logger.error("REST API 연결 목록 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "서버 내부 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } +); + +/** + * GET /api/external-rest-api-connections/:id + * REST API 연결 상세 조회 + */ +router.get( + "/:id", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + try { + const id = parseInt(req.params.id); + + if (isNaN(id)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 ID입니다.", + }); + } + + const result = + await ExternalRestApiConnectionService.getConnectionById(id); + + return res.status(result.success ? 200 : 404).json(result); + } catch (error) { + logger.error("REST API 연결 상세 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "서버 내부 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } +); + +/** + * POST /api/external-rest-api-connections + * REST API 연결 생성 + */ +router.post( + "/", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + try { + const data: ExternalRestApiConnection = { + ...req.body, + created_by: req.user?.userId || "system", + }; + + const result = + await ExternalRestApiConnectionService.createConnection(data); + + return res.status(result.success ? 201 : 400).json(result); + } catch (error) { + logger.error("REST API 연결 생성 오류:", error); + return res.status(500).json({ + success: false, + message: "서버 내부 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } +); + +/** + * PUT /api/external-rest-api-connections/:id + * REST API 연결 수정 + */ +router.put( + "/:id", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + try { + const id = parseInt(req.params.id); + + if (isNaN(id)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 ID입니다.", + }); + } + + const data: Partial = { + ...req.body, + updated_by: req.user?.userId || "system", + }; + + const result = await ExternalRestApiConnectionService.updateConnection( + id, + data + ); + + return res.status(result.success ? 200 : 400).json(result); + } catch (error) { + logger.error("REST API 연결 수정 오류:", error); + return res.status(500).json({ + success: false, + message: "서버 내부 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } +); + +/** + * DELETE /api/external-rest-api-connections/:id + * REST API 연결 삭제 + */ +router.delete( + "/:id", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + try { + const id = parseInt(req.params.id); + + if (isNaN(id)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 ID입니다.", + }); + } + + const result = + await ExternalRestApiConnectionService.deleteConnection(id); + + return res.status(result.success ? 200 : 404).json(result); + } catch (error) { + logger.error("REST API 연결 삭제 오류:", error); + return res.status(500).json({ + success: false, + message: "서버 내부 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } +); + +/** + * POST /api/external-rest-api-connections/test + * REST API 연결 테스트 (테스트 데이터 기반) + */ +router.post( + "/test", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + try { + const testRequest: RestApiTestRequest = req.body; + + if (!testRequest.base_url) { + return res.status(400).json({ + success: false, + message: "기본 URL은 필수입니다.", + }); + } + + const result = + await ExternalRestApiConnectionService.testConnection(testRequest); + + return res.status(200).json(result); + } catch (error) { + logger.error("REST API 연결 테스트 오류:", error); + return res.status(500).json({ + success: false, + message: "서버 내부 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } +); + +/** + * POST /api/external-rest-api-connections/:id/test + * REST API 연결 테스트 (ID 기반) + */ +router.post( + "/:id/test", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + try { + const id = parseInt(req.params.id); + + if (isNaN(id)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 ID입니다.", + }); + } + + const endpoint = req.body.endpoint as string | undefined; + + const result = await ExternalRestApiConnectionService.testConnectionById( + id, + endpoint + ); + + return res.status(200).json(result); + } catch (error) { + logger.error("REST API 연결 테스트 (ID) 오류:", error); + return res.status(500).json({ + success: false, + message: "서버 내부 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } +); + +export default router; diff --git a/backend-node/src/routes/flowRoutes.ts b/backend-node/src/routes/flowRoutes.ts index 93c59ad1..06c6795b 100644 --- a/backend-node/src/routes/flowRoutes.ts +++ b/backend-node/src/routes/flowRoutes.ts @@ -4,6 +4,7 @@ import { Router } from "express"; import { FlowController } from "../controllers/flowController"; +import { authenticateToken } from "../middleware/authMiddleware"; const router = Router(); const flowController = new FlowController(); @@ -32,8 +33,8 @@ router.get("/:flowId/step/:stepId/list", flowController.getStepDataList); router.get("/:flowId/steps/counts", flowController.getAllStepCounts); // ==================== 데이터 이동 ==================== -router.post("/move", flowController.moveData); -router.post("/move-batch", flowController.moveBatchData); +router.post("/move", authenticateToken, flowController.moveData); +router.post("/move-batch", authenticateToken, flowController.moveBatchData); // ==================== 오딧 로그 ==================== router.get("/audit/:flowId/:recordId", flowController.getAuditLogs); diff --git a/backend-node/src/routes/mailReceiveBasicRoutes.ts b/backend-node/src/routes/mailReceiveBasicRoutes.ts index d40c4629..60676ef6 100644 --- a/backend-node/src/routes/mailReceiveBasicRoutes.ts +++ b/backend-node/src/routes/mailReceiveBasicRoutes.ts @@ -27,6 +27,9 @@ router.get('/:accountId/:seqno/attachment/:index', (req, res) => { // 메일 읽음 표시 - 구체적인 경로 router.post('/:accountId/:seqno/mark-read', (req, res) => controller.markAsRead(req, res)); +// 메일 삭제 - 구체적인 경로 +router.delete('/:accountId/:seqno', (req, res) => controller.deleteMail(req, res)); + // 메일 상세 조회 - /:accountId보다 먼저 정의해야 함 router.get('/:accountId/:seqno', (req, res) => controller.getMailDetail(req, res)); diff --git a/backend-node/src/routes/mailSendSimpleRoutes.ts b/backend-node/src/routes/mailSendSimpleRoutes.ts index f354957c..12c1ccff 100644 --- a/backend-node/src/routes/mailSendSimpleRoutes.ts +++ b/backend-node/src/routes/mailSendSimpleRoutes.ts @@ -15,6 +15,9 @@ router.post( (req, res) => mailSendSimpleController.sendMail(req, res) ); +// POST /api/mail/send/bulk - 대량 메일 발송 +router.post('/bulk', (req, res) => mailSendSimpleController.sendBulkMail(req, res)); + // POST /api/mail/send/test-connection - SMTP 연결 테스트 router.post('/test-connection', (req, res) => mailSendSimpleController.testConnection(req, res)); diff --git a/backend-node/src/routes/mailSentHistoryRoutes.ts b/backend-node/src/routes/mailSentHistoryRoutes.ts index 2f4c6f98..5863eed9 100644 --- a/backend-node/src/routes/mailSentHistoryRoutes.ts +++ b/backend-node/src/routes/mailSentHistoryRoutes.ts @@ -7,16 +7,37 @@ const router = Router(); // 모든 라우트에 인증 미들웨어 적용 router.use(authenticateToken); +// GET /api/mail/sent/statistics - 통계 조회 (⚠️ 반드시 /:id 보다 먼저 정의) +router.get('/statistics', (req, res) => mailSentHistoryController.getStatistics(req, res)); + // GET /api/mail/sent - 발송 이력 목록 조회 router.get('/', (req, res) => mailSentHistoryController.getList(req, res)); -// GET /api/mail/sent/statistics - 통계 조회 -router.get('/statistics', (req, res) => mailSentHistoryController.getStatistics(req, res)); +// POST /api/mail/sent/draft - 임시 저장 (Draft) +router.post('/draft', (req, res) => mailSentHistoryController.saveDraft(req, res)); + +// PUT /api/mail/sent/draft/:id - 임시 저장 업데이트 +router.put('/draft/:id', (req, res) => mailSentHistoryController.updateDraft(req, res)); + +// POST /api/mail/sent/bulk/delete - 일괄 삭제 +router.post('/bulk/delete', (req, res) => mailSentHistoryController.bulkDelete(req, res)); + +// POST /api/mail/sent/bulk/permanent-delete - 일괄 영구 삭제 +router.post('/bulk/permanent-delete', (req, res) => mailSentHistoryController.bulkPermanentDelete(req, res)); + +// POST /api/mail/sent/bulk/restore - 일괄 복구 +router.post('/bulk/restore', (req, res) => mailSentHistoryController.bulkRestore(req, res)); + +// POST /api/mail/sent/:id/restore - 메일 복구 +router.post('/:id/restore', (req, res) => mailSentHistoryController.restoreMail(req, res)); + +// DELETE /api/mail/sent/:id/permanent - 메일 영구 삭제 +router.delete('/:id/permanent', (req, res) => mailSentHistoryController.permanentlyDelete(req, res)); // GET /api/mail/sent/:id - 특정 발송 이력 상세 조회 router.get('/:id', (req, res) => mailSentHistoryController.getById(req, res)); -// DELETE /api/mail/sent/:id - 발송 이력 삭제 +// DELETE /api/mail/sent/:id - 발송 이력 삭제 (Soft Delete) router.delete('/:id', (req, res) => mailSentHistoryController.deleteById(req, res)); export default router; diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index c0b35b94..5e5ddf38 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -18,6 +18,10 @@ import { checkTableExists, getColumnWebTypes, checkDatabaseConnection, + createLogTable, + getLogConfig, + getLogData, + toggleLogTable, } from "../controllers/tableManagementController"; const router = express.Router(); @@ -148,4 +152,32 @@ router.put("/tables/:tableName/edit", editTableData); */ router.delete("/tables/:tableName/delete", deleteTableData); +// ======================================== +// 테이블 로그 시스템 API +// ======================================== + +/** + * 로그 테이블 생성 + * POST /api/table-management/tables/:tableName/log + */ +router.post("/tables/:tableName/log", createLogTable); + +/** + * 로그 설정 조회 + * GET /api/table-management/tables/:tableName/log/config + */ +router.get("/tables/:tableName/log/config", getLogConfig); + +/** + * 로그 데이터 조회 + * GET /api/table-management/tables/:tableName/log + */ +router.get("/tables/:tableName/log", getLogData); + +/** + * 로그 테이블 활성화/비활성화 + * POST /api/table-management/tables/:tableName/log/toggle + */ +router.post("/tables/:tableName/log/toggle", toggleLogTable); + export default router; diff --git a/backend-node/src/services/DashboardService.ts b/backend-node/src/services/DashboardService.ts index c7650df2..92b5ed39 100644 --- a/backend-node/src/services/DashboardService.ts +++ b/backend-node/src/services/DashboardService.ts @@ -18,7 +18,8 @@ export class DashboardService { */ static async createDashboard( data: CreateDashboardRequest, - userId: string + userId: string, + companyCode?: string ): Promise { const dashboardId = uuidv4(); const now = new Date(); @@ -31,8 +32,8 @@ export class DashboardService { ` INSERT INTO dashboards ( id, title, description, is_public, created_by, - created_at, updated_at, tags, category, view_count, settings - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + created_at, updated_at, tags, category, view_count, settings, company_code + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) `, [ dashboardId, @@ -46,6 +47,7 @@ export class DashboardService { data.category || null, 0, JSON.stringify(data.settings || {}), + companyCode || "DEFAULT", ] ); @@ -61,9 +63,9 @@ export class DashboardService { id, dashboard_id, element_type, element_subtype, position_x, position_y, width, height, title, custom_title, show_header, content, data_source_config, chart_config, - list_config, yard_config, + list_config, yard_config, custom_metric_config, display_order, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) `, [ elementId, @@ -82,6 +84,7 @@ export class DashboardService { JSON.stringify(element.chartConfig || {}), JSON.stringify(element.listConfig || null), JSON.stringify(element.yardConfig || null), + JSON.stringify(element.customMetricConfig || null), i, now, now, @@ -143,7 +146,11 @@ export class DashboardService { /** * 대시보드 목록 조회 */ - static async getDashboards(query: DashboardListQuery, userId?: string) { + static async getDashboards( + query: DashboardListQuery, + userId?: string, + companyCode?: string + ) { const { page = 1, limit = 20, @@ -161,6 +168,13 @@ export class DashboardService { let params: any[] = []; let paramIndex = 1; + // 회사 코드 필터링 (최우선) + if (companyCode) { + whereConditions.push(`d.company_code = $${paramIndex}`); + params.push(companyCode); + paramIndex++; + } + // 권한 필터링 if (userId) { whereConditions.push( @@ -278,7 +292,8 @@ export class DashboardService { */ static async getDashboardById( dashboardId: string, - userId?: string + userId?: string, + companyCode?: string ): Promise { try { // 1. 대시보드 기본 정보 조회 (권한 체크 포함) @@ -286,21 +301,43 @@ export class DashboardService { let dashboardParams: any[]; if (userId) { - dashboardQuery = ` - SELECT d.* - FROM dashboards d - WHERE d.id = $1 AND d.deleted_at IS NULL - AND (d.created_by = $2 OR d.is_public = true) - `; - dashboardParams = [dashboardId, userId]; + if (companyCode) { + dashboardQuery = ` + SELECT d.* + FROM dashboards d + WHERE d.id = $1 AND d.deleted_at IS NULL + AND d.company_code = $2 + AND (d.created_by = $3 OR d.is_public = true) + `; + dashboardParams = [dashboardId, companyCode, userId]; + } else { + dashboardQuery = ` + SELECT d.* + FROM dashboards d + WHERE d.id = $1 AND d.deleted_at IS NULL + AND (d.created_by = $2 OR d.is_public = true) + `; + dashboardParams = [dashboardId, userId]; + } } else { - dashboardQuery = ` - SELECT d.* - FROM dashboards d - WHERE d.id = $1 AND d.deleted_at IS NULL - AND d.is_public = true - `; - dashboardParams = [dashboardId]; + if (companyCode) { + dashboardQuery = ` + SELECT d.* + FROM dashboards d + WHERE d.id = $1 AND d.deleted_at IS NULL + AND d.company_code = $2 + AND d.is_public = true + `; + dashboardParams = [dashboardId, companyCode]; + } else { + dashboardQuery = ` + SELECT d.* + FROM dashboards d + WHERE d.id = $1 AND d.deleted_at IS NULL + AND d.is_public = true + `; + dashboardParams = [dashboardId]; + } } const dashboardResult = await PostgreSQLService.query( @@ -355,6 +392,11 @@ export class DashboardService { ? JSON.parse(row.yard_config) : row.yard_config : undefined, + customMetricConfig: row.custom_metric_config + ? typeof row.custom_metric_config === "string" + ? JSON.parse(row.custom_metric_config) + : row.custom_metric_config + : undefined, }) ); @@ -478,9 +520,9 @@ export class DashboardService { id, dashboard_id, element_type, element_subtype, position_x, position_y, width, height, title, custom_title, show_header, content, data_source_config, chart_config, - list_config, yard_config, + list_config, yard_config, custom_metric_config, display_order, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) `, [ elementId, @@ -499,6 +541,7 @@ export class DashboardService { JSON.stringify(element.chartConfig || {}), JSON.stringify(element.listConfig || null), JSON.stringify(element.yardConfig || null), + JSON.stringify(element.customMetricConfig || null), i, now, now, diff --git a/backend-node/src/services/dbQueryBuilder.ts b/backend-node/src/services/dbQueryBuilder.ts new file mode 100644 index 00000000..e7b8d9bc --- /dev/null +++ b/backend-node/src/services/dbQueryBuilder.ts @@ -0,0 +1,230 @@ +/** + * 데이터베이스별 쿼리 빌더 + * PostgreSQL, MySQL/MariaDB, MSSQL, Oracle 지원 + */ + +export type DbType = "postgresql" | "mysql" | "mariadb" | "mssql" | "oracle"; + +/** + * DB별 파라미터 플레이스홀더 생성 + */ +export function getPlaceholder(dbType: string, index: number): string { + const normalizedType = dbType.toLowerCase(); + + switch (normalizedType) { + case "postgresql": + return `$${index}`; + + case "mysql": + case "mariadb": + return "?"; + + case "mssql": + return `@p${index}`; + + case "oracle": + return `:${index}`; + + default: + // 기본값은 PostgreSQL + return `$${index}`; + } +} + +/** + * UPDATE 쿼리 생성 + */ +export function buildUpdateQuery( + dbType: string, + tableName: string, + updates: { column: string; value: any }[], + whereColumn: string = "id" +): { query: string; values: any[] } { + const normalizedType = dbType.toLowerCase(); + const values: any[] = []; + + // SET 절 생성 + const setClause = updates + .map((update, index) => { + values.push(update.value); + const placeholder = getPlaceholder(normalizedType, values.length); + return `${update.column} = ${placeholder}`; + }) + .join(", "); + + // WHERE 절 생성 + values.push(undefined); // whereValue는 나중에 설정 + const wherePlaceholder = getPlaceholder(normalizedType, values.length); + + // updated_at 처리 (DB별 NOW() 함수) + let updatedAtExpr = "NOW()"; + if (normalizedType === "mssql") { + updatedAtExpr = "GETDATE()"; + } else if (normalizedType === "oracle") { + updatedAtExpr = "SYSDATE"; + } + + const query = ` + UPDATE ${tableName} + SET ${setClause}, updated_at = ${updatedAtExpr} + WHERE ${whereColumn} = ${wherePlaceholder} + `; + + return { query, values }; +} + +/** + * INSERT 쿼리 생성 + */ +export function buildInsertQuery( + dbType: string, + tableName: string, + data: Record +): { query: string; values: any[]; returningClause: string } { + const normalizedType = dbType.toLowerCase(); + const columns = Object.keys(data); + const values = Object.values(data); + + // 플레이스홀더 생성 + const placeholders = columns + .map((_, index) => getPlaceholder(normalizedType, index + 1)) + .join(", "); + + let query = ` + INSERT INTO ${tableName} (${columns.join(", ")}) + VALUES (${placeholders}) + `; + + // RETURNING/OUTPUT 절 추가 (DB별로 다름) + let returningClause = ""; + if (normalizedType === "postgresql") { + query += " RETURNING id"; + returningClause = "RETURNING id"; + } else if (normalizedType === "mssql") { + // MSSQL은 OUTPUT 절을 INSERT와 VALUES 사이에 + const insertIndex = query.indexOf("VALUES"); + query = + query.substring(0, insertIndex) + + "OUTPUT INSERTED.id " + + query.substring(insertIndex); + returningClause = "OUTPUT INSERTED.id"; + } else if (normalizedType === "oracle") { + query += " RETURNING id INTO :out_id"; + returningClause = "RETURNING id INTO :out_id"; + } + // MySQL/MariaDB는 RETURNING 없음, LAST_INSERT_ID() 사용 + + return { query, values, returningClause }; +} + +/** + * SELECT 쿼리 생성 + */ +export function buildSelectQuery( + dbType: string, + tableName: string, + whereColumn: string = "id" +): { query: string; placeholder: string } { + const normalizedType = dbType.toLowerCase(); + const placeholder = getPlaceholder(normalizedType, 1); + + const query = `SELECT * FROM ${tableName} WHERE ${whereColumn} = ${placeholder}`; + + return { query, placeholder }; +} + +/** + * LIMIT/OFFSET 쿼리 생성 (페이징) + */ +export function buildPaginationClause( + dbType: string, + limit?: number, + offset?: number +): string { + const normalizedType = dbType.toLowerCase(); + + if (!limit) { + return ""; + } + + if ( + normalizedType === "postgresql" || + normalizedType === "mysql" || + normalizedType === "mariadb" + ) { + // PostgreSQL, MySQL, MariaDB: LIMIT ... OFFSET ... + let clause = ` LIMIT ${limit}`; + if (offset) { + clause += ` OFFSET ${offset}`; + } + return clause; + } else if (normalizedType === "mssql") { + // MSSQL: OFFSET ... ROWS FETCH NEXT ... ROWS ONLY + if (offset) { + return ` OFFSET ${offset} ROWS FETCH NEXT ${limit} ROWS ONLY`; + } else { + return ` OFFSET 0 ROWS FETCH NEXT ${limit} ROWS ONLY`; + } + } else if (normalizedType === "oracle") { + // Oracle: ROWNUM 또는 FETCH FIRST (12c+) + if (offset) { + return ` OFFSET ${offset} ROWS FETCH NEXT ${limit} ROWS ONLY`; + } else { + return ` FETCH FIRST ${limit} ROWS ONLY`; + } + } + + return ""; +} + +/** + * 트랜잭션 시작 + */ +export function getBeginTransactionQuery(dbType: string): string { + const normalizedType = dbType.toLowerCase(); + + if (normalizedType === "mssql") { + return "BEGIN TRANSACTION"; + } + + return "BEGIN"; +} + +/** + * 트랜잭션 커밋 + */ +export function getCommitQuery(dbType: string): string { + return "COMMIT"; +} + +/** + * 트랜잭션 롤백 + */ +export function getRollbackQuery(dbType: string): string { + return "ROLLBACK"; +} + +/** + * DB 연결 테스트 쿼리 + */ +export function getConnectionTestQuery(dbType: string): string { + const normalizedType = dbType.toLowerCase(); + + switch (normalizedType) { + case "postgresql": + return "SELECT 1"; + + case "mysql": + case "mariadb": + return "SELECT 1"; + + case "mssql": + return "SELECT 1"; + + case "oracle": + return "SELECT 1 FROM DUAL"; + + default: + return "SELECT 1"; + } +} diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 331f980e..999ea6d2 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -1,4 +1,4 @@ -import { query, queryOne } from "../database/db"; +import { query, queryOne, transaction } from "../database/db"; import { EventTriggerService } from "./eventTriggerService"; import { DataflowControlService } from "./dataflowControlService"; @@ -203,7 +203,8 @@ export class DynamicFormService { async saveFormData( screenId: number, tableName: string, - data: Record + data: Record, + ipAddress?: string ): Promise { try { console.log("💾 서비스: 실제 테이블에 폼 데이터 저장 시작:", { @@ -432,7 +433,19 @@ export class DynamicFormService { console.log("📝 실행할 UPSERT SQL:", upsertQuery); console.log("📊 SQL 파라미터:", values); - const result = await query(upsertQuery, values); + // 로그 트리거를 위한 세션 변수 설정 및 UPSERT 실행 (트랜잭션 내에서) + const userId = data.updated_by || data.created_by || "system"; + const clientIp = ipAddress || "unknown"; + + const result = await transaction(async (client) => { + // 세션 변수 설정 + await client.query(`SET LOCAL app.user_id = '${userId}'`); + await client.query(`SET LOCAL app.ip_address = '${clientIp}'`); + + // UPSERT 실행 + const res = await client.query(upsertQuery, values); + return res.rows; + }); console.log("✅ 서비스: 실제 테이블 저장 성공:", result); diff --git a/backend-node/src/services/externalDbHelper.ts b/backend-node/src/services/externalDbHelper.ts new file mode 100644 index 00000000..9a8b4f7d --- /dev/null +++ b/backend-node/src/services/externalDbHelper.ts @@ -0,0 +1,467 @@ +/** + * 외부 DB 연결 헬퍼 + * 플로우 데이터 이동 시 외부 DB 연결 관리 + * PostgreSQL, MySQL/MariaDB, MSSQL, Oracle 지원 + */ + +import { Pool as PgPool } from "pg"; +import * as mysql from "mysql2/promise"; +import db from "../database/db"; +import { PasswordEncryption } from "../utils/passwordEncryption"; +import { + getConnectionTestQuery, + getPlaceholder, + getBeginTransactionQuery, + getCommitQuery, + getRollbackQuery, +} from "./dbQueryBuilder"; + +interface ExternalDbConnection { + id: number; + connectionName: string; + dbType: string; + host: string; + port: number; + database: string; + username: string; + password: string; + isActive: boolean; +} + +// 외부 DB 연결 풀 캐시 (타입별로 다른 풀 객체) +const connectionPools = new Map(); + +/** + * 외부 DB 연결 정보 조회 + */ +async function getExternalConnection( + connectionId: number +): Promise { + const query = `SELECT * FROM external_db_connections WHERE id = $1 AND is_active = 'Y'`; + + const result = await db.query(query, [connectionId]); + + if (result.length === 0) { + return null; + } + + const row = result[0]; + + // 비밀번호 복호화 (암호화된 비밀번호는 password 컬럼에 저장됨) + let decryptedPassword = ""; + try { + decryptedPassword = PasswordEncryption.decrypt(row.password); + } catch (error) { + console.error(`비밀번호 복호화 실패 (ID: ${connectionId}):`, error); + // 복호화 실패 시 원본 비밀번호 사용 (fallback) + decryptedPassword = row.password; + } + + return { + id: row.id, + connectionName: row.connection_name, + dbType: row.db_type, + host: row.host, + port: row.port, + database: row.database_name, + username: row.username, + password: decryptedPassword, + isActive: row.is_active, + }; +} + +/** + * 외부 DB 연결 풀 생성 또는 재사용 + */ +export async function getExternalPool(connectionId: number): Promise { + // 캐시된 연결 풀 확인 + if (connectionPools.has(connectionId)) { + const poolInfo = connectionPools.get(connectionId)!; + const connection = await getExternalConnection(connectionId); + + // 연결이 유효한지 확인 + try { + const testQuery = getConnectionTestQuery(connection!.dbType); + await executePoolQuery(poolInfo.pool, connection!.dbType, testQuery, []); + return poolInfo; + } catch (error) { + console.warn( + `캐시된 외부 DB 연결 풀 무효화 (ID: ${connectionId}), 재생성합니다.` + ); + connectionPools.delete(connectionId); + await closePool(poolInfo.pool, connection!.dbType); + } + } + + // 새로운 연결 풀 생성 + const connection = await getExternalConnection(connectionId); + + if (!connection) { + throw new Error( + `외부 DB 연결 정보를 찾을 수 없습니다 (ID: ${connectionId})` + ); + } + + const dbType = connection.dbType.toLowerCase(); + let pool: any; + + try { + switch (dbType) { + case "postgresql": + pool = await createPostgreSQLPool(connection); + break; + + case "mysql": + case "mariadb": + pool = await createMySQLPool(connection); + break; + + case "mssql": + pool = await createMSSQLPool(connection); + break; + + case "oracle": + pool = await createOraclePool(connection); + break; + + default: + throw new Error(`지원하지 않는 DB 타입입니다: ${connection.dbType}`); + } + + // 연결 테스트 + const testQuery = getConnectionTestQuery(dbType); + await executePoolQuery(pool, dbType, testQuery, []); + + console.log( + `✅ 외부 DB 연결 풀 생성 성공 (ID: ${connectionId}, ${connection.connectionName}, ${connection.dbType})` + ); + + // 캐시에 저장 (dbType 정보 포함) + const poolInfo = { pool, dbType }; + connectionPools.set(connectionId, poolInfo); + + return poolInfo; + } catch (error) { + if (pool) { + await closePool(pool, dbType); + } + throw new Error( + `외부 DB 연결 실패 (${connection.connectionName}, ${connection.dbType}): ${error}` + ); + } +} + +/** + * PostgreSQL 연결 풀 생성 + */ +async function createPostgreSQLPool( + connection: ExternalDbConnection +): Promise { + return new PgPool({ + host: connection.host, + port: connection.port, + database: connection.database, + user: connection.username, + password: connection.password, + max: 5, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 5000, + }); +} + +/** + * MySQL/MariaDB 연결 풀 생성 + */ +async function createMySQLPool( + connection: ExternalDbConnection +): Promise { + return mysql.createPool({ + host: connection.host, + port: connection.port, + database: connection.database, + user: connection.username, + password: connection.password, + connectionLimit: 5, + waitForConnections: true, + queueLimit: 0, + }); +} + +/** + * MSSQL 연결 풀 생성 + */ +async function createMSSQLPool(connection: ExternalDbConnection): Promise { + // mssql 패키지를 동적으로 import (설치되어 있는 경우만) + try { + const sql = require("mssql"); + const config = { + user: connection.username, + password: connection.password, + server: connection.host, + port: connection.port, + database: connection.database, + options: { + encrypt: true, + trustServerCertificate: true, + enableArithAbort: true, + }, + pool: { + max: 5, + min: 0, + idleTimeoutMillis: 30000, + }, + }; + + const pool = await sql.connect(config); + return pool; + } catch (error) { + throw new Error( + `MSSQL 연결 실패: mssql 패키지가 설치되어 있는지 확인하세요. (${error})` + ); + } +} + +/** + * Oracle 연결 풀 생성 + */ +async function createOraclePool( + connection: ExternalDbConnection +): Promise { + try { + // oracledb를 동적으로 import + const oracledb = require("oracledb"); + + // Oracle 클라이언트 초기화 (최초 1회만) + if (!oracledb.oracleClientVersion) { + // Instant Client 경로 설정 (환경변수로 지정 가능) + const instantClientPath = process.env.ORACLE_INSTANT_CLIENT_PATH; + if (instantClientPath) { + oracledb.initOracleClient({ libDir: instantClientPath }); + } + } + + // 연결 문자열 생성 + const connectString = connection.database.includes("/") + ? connection.database // 이미 전체 연결 문자열인 경우 + : `${connection.host}:${connection.port}/${connection.database}`; + + const pool = await oracledb.createPool({ + user: connection.username, + password: connection.password, + connectString: connectString, + poolMin: 1, + poolMax: 5, + poolIncrement: 1, + poolTimeout: 60, // 60초 후 유휴 연결 해제 + queueTimeout: 5000, // 연결 대기 타임아웃 5초 + enableStatistics: true, + }); + + return pool; + } catch (error: any) { + throw new Error( + `Oracle 연결 실패: ${error.message}. oracledb 패키지와 Oracle Instant Client가 설치되어 있는지 확인하세요.` + ); + } +} + +/** + * 풀에서 쿼리 실행 (DB 타입별 처리) + */ +async function executePoolQuery( + pool: any, + dbType: string, + query: string, + params: any[] +): Promise { + const normalizedType = dbType.toLowerCase(); + + switch (normalizedType) { + case "postgresql": { + const result = await pool.query(query, params); + return { rows: result.rows, rowCount: result.rowCount }; + } + + case "mysql": + case "mariadb": { + const [rows] = await pool.query(query, params); + return { + rows: Array.isArray(rows) ? rows : [rows], + rowCount: rows.length, + }; + } + + case "mssql": { + const request = pool.request(); + // MSSQL은 명명된 파라미터 사용 + params.forEach((param, index) => { + request.input(`p${index + 1}`, param); + }); + const result = await request.query(query); + return { rows: result.recordset, rowCount: result.rowCount }; + } + + case "oracle": { + const oracledb = require("oracledb"); + const connection = await pool.getConnection(); + try { + // Oracle은 :1, :2 형식의 바인드 변수 사용 + const result = await connection.execute(query, params, { + autoCommit: false, // 트랜잭션 관리를 위해 false + outFormat: oracledb.OUT_FORMAT_OBJECT, // 객체 형식으로 반환 + }); + return { rows: result.rows || [], rowCount: result.rowCount || 0 }; + } finally { + await connection.close(); + } + } + + default: + throw new Error(`지원하지 않는 DB 타입: ${dbType}`); + } +} + +/** + * 연결 풀 종료 (DB 타입별 처리) + */ +async function closePool(pool: any, dbType: string): Promise { + const normalizedType = dbType.toLowerCase(); + + try { + switch (normalizedType) { + case "postgresql": + case "mysql": + case "mariadb": + await pool.end(); + break; + + case "mssql": + case "oracle": + await pool.close(); + break; + } + } catch (error) { + console.error(`풀 종료 오류 (${dbType}):`, error); + } +} + +/** + * 외부 DB 쿼리 실행 + */ +export async function executeExternalQuery( + connectionId: number, + query: string, + params: any[] = [] +): Promise { + const poolInfo = await getExternalPool(connectionId); + return await executePoolQuery(poolInfo.pool, poolInfo.dbType, query, params); +} + +/** + * 외부 DB 트랜잭션 실행 + */ +export async function executeExternalTransaction( + connectionId: number, + callback: (client: any, dbType: string) => Promise +): Promise { + const poolInfo = await getExternalPool(connectionId); + const { pool, dbType } = poolInfo; + const normalizedType = dbType.toLowerCase(); + + let client: any; + + try { + switch (normalizedType) { + case "postgresql": { + client = await pool.connect(); + await client.query(getBeginTransactionQuery(dbType)); + const result = await callback(client, dbType); + await client.query(getCommitQuery(dbType)); + return result; + } + + case "mysql": + case "mariadb": { + client = await pool.getConnection(); + await client.beginTransaction(); + const result = await callback(client, dbType); + await client.commit(); + return result; + } + + case "mssql": { + const transaction = new pool.constructor.Transaction(pool); + await transaction.begin(); + client = transaction; + const result = await callback(client, dbType); + await transaction.commit(); + return result; + } + + case "oracle": { + client = await pool.getConnection(); + // Oracle은 명시적 BEGIN 없이 트랜잭션 시작 + const result = await callback(client, dbType); + // 명시적 커밋 + await client.commit(); + return result; + } + + default: + throw new Error(`지원하지 않는 DB 타입: ${dbType}`); + } + } catch (error) { + console.error(`외부 DB 트랜잭션 오류 (ID: ${connectionId}):`, error); + + // 롤백 시도 + if (client) { + try { + switch (normalizedType) { + case "postgresql": + await client.query(getRollbackQuery(dbType)); + break; + + case "mysql": + case "mariadb": + await client.rollback(); + break; + + case "mssql": + case "oracle": + await client.rollback(); + break; + } + } catch (rollbackError) { + console.error("트랜잭션 롤백 오류:", rollbackError); + } + } + + throw error; + } finally { + // 연결 해제 + if (client) { + try { + switch (normalizedType) { + case "postgresql": + client.release(); + break; + + case "mysql": + case "mariadb": + client.release(); + break; + + case "oracle": + await client.close(); + break; + + case "mssql": + // MSSQL Transaction 객체는 자동으로 정리됨 + break; + } + } catch (releaseError) { + console.error("클라이언트 해제 오류:", releaseError); + } + } + } +} diff --git a/backend-node/src/services/externalRestApiConnectionService.ts b/backend-node/src/services/externalRestApiConnectionService.ts new file mode 100644 index 00000000..4d0539b4 --- /dev/null +++ b/backend-node/src/services/externalRestApiConnectionService.ts @@ -0,0 +1,669 @@ +import { Pool, QueryResult } from "pg"; +import { getPool } from "../database/db"; +import logger from "../utils/logger"; +import { + ExternalRestApiConnection, + ExternalRestApiConnectionFilter, + RestApiTestRequest, + RestApiTestResult, + AuthType, +} from "../types/externalRestApiTypes"; +import { ApiResponse } from "../types/common"; +import crypto from "crypto"; + +const pool = getPool(); + +// 암호화 설정 +const ENCRYPTION_KEY = + process.env.DB_PASSWORD_SECRET || "default-secret-key-change-in-production"; +const ALGORITHM = "aes-256-gcm"; + +export class ExternalRestApiConnectionService { + /** + * REST API 연결 목록 조회 + */ + static async getConnections( + filter: ExternalRestApiConnectionFilter = {} + ): Promise> { + try { + let query = ` + SELECT + id, connection_name, description, base_url, default_headers, + auth_type, auth_config, timeout, retry_count, retry_delay, + company_code, is_active, created_date, created_by, + updated_date, updated_by, last_test_date, last_test_result, last_test_message + FROM external_rest_api_connections + WHERE 1=1 + `; + + const params: any[] = []; + let paramIndex = 1; + + // 회사 코드 필터 + if (filter.company_code) { + query += ` AND company_code = $${paramIndex}`; + params.push(filter.company_code); + paramIndex++; + } + + // 활성 상태 필터 + if (filter.is_active) { + query += ` AND is_active = $${paramIndex}`; + params.push(filter.is_active); + paramIndex++; + } + + // 인증 타입 필터 + if (filter.auth_type) { + query += ` AND auth_type = $${paramIndex}`; + params.push(filter.auth_type); + paramIndex++; + } + + // 검색어 필터 (연결명, 설명, URL) + if (filter.search) { + query += ` AND ( + connection_name ILIKE $${paramIndex} OR + description ILIKE $${paramIndex} OR + base_url ILIKE $${paramIndex} + )`; + params.push(`%${filter.search}%`); + paramIndex++; + } + + query += ` ORDER BY created_date DESC`; + + const result: QueryResult = await pool.query(query, params); + + // 민감 정보 복호화 + const connections = result.rows.map((row: any) => ({ + ...row, + auth_config: row.auth_config + ? this.decryptSensitiveData(row.auth_config) + : null, + })); + + return { + success: true, + data: connections, + message: `${connections.length}개의 연결을 조회했습니다.`, + }; + } catch (error) { + logger.error("REST API 연결 목록 조회 오류:", error); + return { + success: false, + message: "연결 목록 조회에 실패했습니다.", + error: { + code: "FETCH_ERROR", + details: error instanceof Error ? error.message : "알 수 없는 오류", + }, + }; + } + } + + /** + * REST API 연결 상세 조회 + */ + static async getConnectionById( + id: number + ): Promise> { + try { + const query = ` + SELECT + id, connection_name, description, base_url, default_headers, + auth_type, auth_config, timeout, retry_count, retry_delay, + company_code, is_active, created_date, created_by, + updated_date, updated_by, last_test_date, last_test_result, last_test_message + FROM external_rest_api_connections + WHERE id = $1 + `; + + const result: QueryResult = await pool.query(query, [id]); + + if (result.rows.length === 0) { + return { + success: false, + message: "연결을 찾을 수 없습니다.", + }; + } + + const connection = result.rows[0]; + connection.auth_config = connection.auth_config + ? this.decryptSensitiveData(connection.auth_config) + : null; + + return { + success: true, + data: connection, + message: "연결을 조회했습니다.", + }; + } catch (error) { + logger.error("REST API 연결 상세 조회 오류:", error); + return { + success: false, + message: "연결 조회에 실패했습니다.", + error: { + code: "FETCH_ERROR", + details: error instanceof Error ? error.message : "알 수 없는 오류", + }, + }; + } + } + + /** + * REST API 연결 생성 + */ + static async createConnection( + data: ExternalRestApiConnection + ): Promise> { + try { + // 유효성 검증 + this.validateConnectionData(data); + + // 민감 정보 암호화 + const encryptedAuthConfig = data.auth_config + ? this.encryptSensitiveData(data.auth_config) + : null; + + const query = ` + INSERT INTO external_rest_api_connections ( + connection_name, description, base_url, default_headers, + auth_type, auth_config, timeout, retry_count, retry_delay, + company_code, is_active, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + RETURNING * + `; + + const params = [ + data.connection_name, + data.description || null, + data.base_url, + JSON.stringify(data.default_headers || {}), + data.auth_type, + encryptedAuthConfig ? JSON.stringify(encryptedAuthConfig) : null, + data.timeout || 30000, + data.retry_count || 0, + data.retry_delay || 1000, + data.company_code || "*", + data.is_active || "Y", + data.created_by || "system", + ]; + + const result: QueryResult = await pool.query(query, params); + + logger.info(`REST API 연결 생성 성공: ${data.connection_name}`); + + return { + success: true, + data: result.rows[0], + message: "연결이 생성되었습니다.", + }; + } catch (error: any) { + logger.error("REST API 연결 생성 오류:", error); + + // 중복 키 오류 처리 + if (error.code === "23505") { + return { + success: false, + message: "이미 존재하는 연결명입니다.", + }; + } + + return { + success: false, + message: "연결 생성에 실패했습니다.", + error: { + code: "CREATE_ERROR", + details: error instanceof Error ? error.message : "알 수 없는 오류", + }, + }; + } + } + + /** + * REST API 연결 수정 + */ + static async updateConnection( + id: number, + data: Partial + ): Promise> { + try { + // 기존 연결 확인 + const existing = await this.getConnectionById(id); + if (!existing.success) { + return existing; + } + + // 민감 정보 암호화 + const encryptedAuthConfig = data.auth_config + ? this.encryptSensitiveData(data.auth_config) + : undefined; + + const updateFields: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + if (data.connection_name !== undefined) { + updateFields.push(`connection_name = $${paramIndex}`); + params.push(data.connection_name); + paramIndex++; + } + + if (data.description !== undefined) { + updateFields.push(`description = $${paramIndex}`); + params.push(data.description); + paramIndex++; + } + + if (data.base_url !== undefined) { + updateFields.push(`base_url = $${paramIndex}`); + params.push(data.base_url); + paramIndex++; + } + + if (data.default_headers !== undefined) { + updateFields.push(`default_headers = $${paramIndex}`); + params.push(JSON.stringify(data.default_headers)); + paramIndex++; + } + + if (data.auth_type !== undefined) { + updateFields.push(`auth_type = $${paramIndex}`); + params.push(data.auth_type); + paramIndex++; + } + + if (encryptedAuthConfig !== undefined) { + updateFields.push(`auth_config = $${paramIndex}`); + params.push(JSON.stringify(encryptedAuthConfig)); + paramIndex++; + } + + if (data.timeout !== undefined) { + updateFields.push(`timeout = $${paramIndex}`); + params.push(data.timeout); + paramIndex++; + } + + if (data.retry_count !== undefined) { + updateFields.push(`retry_count = $${paramIndex}`); + params.push(data.retry_count); + paramIndex++; + } + + if (data.retry_delay !== undefined) { + updateFields.push(`retry_delay = $${paramIndex}`); + params.push(data.retry_delay); + paramIndex++; + } + + if (data.is_active !== undefined) { + updateFields.push(`is_active = $${paramIndex}`); + params.push(data.is_active); + paramIndex++; + } + + if (data.updated_by !== undefined) { + updateFields.push(`updated_by = $${paramIndex}`); + params.push(data.updated_by); + paramIndex++; + } + + updateFields.push(`updated_date = NOW()`); + + params.push(id); + + const query = ` + UPDATE external_rest_api_connections + SET ${updateFields.join(", ")} + WHERE id = $${paramIndex} + RETURNING * + `; + + const result: QueryResult = await pool.query(query, params); + + logger.info(`REST API 연결 수정 성공: ID ${id}`); + + return { + success: true, + data: result.rows[0], + message: "연결이 수정되었습니다.", + }; + } catch (error: any) { + logger.error("REST API 연결 수정 오류:", error); + + if (error.code === "23505") { + return { + success: false, + message: "이미 존재하는 연결명입니다.", + }; + } + + return { + success: false, + message: "연결 수정에 실패했습니다.", + error: { + code: "UPDATE_ERROR", + details: error instanceof Error ? error.message : "알 수 없는 오류", + }, + }; + } + } + + /** + * REST API 연결 삭제 + */ + static async deleteConnection(id: number): Promise> { + try { + const query = ` + DELETE FROM external_rest_api_connections + WHERE id = $1 + RETURNING connection_name + `; + + const result: QueryResult = await pool.query(query, [id]); + + if (result.rows.length === 0) { + return { + success: false, + message: "연결을 찾을 수 없습니다.", + }; + } + + logger.info(`REST API 연결 삭제 성공: ${result.rows[0].connection_name}`); + + return { + success: true, + message: "연결이 삭제되었습니다.", + }; + } catch (error) { + logger.error("REST API 연결 삭제 오류:", error); + return { + success: false, + message: "연결 삭제에 실패했습니다.", + error: { + code: "DELETE_ERROR", + details: error instanceof Error ? error.message : "알 수 없는 오류", + }, + }; + } + } + + /** + * REST API 연결 테스트 (테스트 요청 데이터 기반) + */ + static async testConnection( + testRequest: RestApiTestRequest + ): Promise { + const startTime = Date.now(); + + try { + // 헤더 구성 + const headers = { ...testRequest.headers }; + + // 인증 헤더 추가 + if ( + testRequest.auth_type === "bearer" && + testRequest.auth_config?.token + ) { + headers["Authorization"] = `Bearer ${testRequest.auth_config.token}`; + } else if (testRequest.auth_type === "basic" && testRequest.auth_config) { + const credentials = Buffer.from( + `${testRequest.auth_config.username}:${testRequest.auth_config.password}` + ).toString("base64"); + headers["Authorization"] = `Basic ${credentials}`; + } else if ( + testRequest.auth_type === "api-key" && + testRequest.auth_config + ) { + if (testRequest.auth_config.keyLocation === "header") { + headers[testRequest.auth_config.keyName] = + testRequest.auth_config.keyValue; + } + } + + // URL 구성 + let url = testRequest.base_url; + if (testRequest.endpoint) { + url = testRequest.endpoint.startsWith("/") + ? `${testRequest.base_url}${testRequest.endpoint}` + : `${testRequest.base_url}/${testRequest.endpoint}`; + } + + // API Key가 쿼리에 있는 경우 + if ( + testRequest.auth_type === "api-key" && + testRequest.auth_config?.keyLocation === "query" && + testRequest.auth_config?.keyName && + testRequest.auth_config?.keyValue + ) { + const separator = url.includes("?") ? "&" : "?"; + url = `${url}${separator}${testRequest.auth_config.keyName}=${testRequest.auth_config.keyValue}`; + } + + logger.info( + `REST API 연결 테스트: ${testRequest.method || "GET"} ${url}` + ); + + // HTTP 요청 실행 + const response = await fetch(url, { + method: testRequest.method || "GET", + headers, + signal: AbortSignal.timeout(testRequest.timeout || 30000), + }); + + const responseTime = Date.now() - startTime; + let responseData = null; + + try { + responseData = await response.json(); + } catch { + // JSON 파싱 실패는 무시 (텍스트 응답일 수 있음) + } + + return { + success: response.ok, + message: response.ok + ? "연결 성공" + : `연결 실패 (${response.status} ${response.statusText})`, + response_time: responseTime, + status_code: response.status, + response_data: responseData, + }; + } catch (error) { + const responseTime = Date.now() - startTime; + + logger.error("REST API 연결 테스트 오류:", error); + + return { + success: false, + message: "연결 실패", + response_time: responseTime, + error_details: + error instanceof Error ? error.message : "알 수 없는 오류", + }; + } + } + + /** + * REST API 연결 테스트 (ID 기반) + */ + static async testConnectionById( + id: number, + endpoint?: string + ): Promise { + try { + const connectionResult = await this.getConnectionById(id); + + if (!connectionResult.success || !connectionResult.data) { + return { + success: false, + message: "연결을 찾을 수 없습니다.", + }; + } + + const connection = connectionResult.data; + + const testRequest: RestApiTestRequest = { + id: connection.id, + base_url: connection.base_url, + endpoint, + headers: connection.default_headers, + auth_type: connection.auth_type, + auth_config: connection.auth_config, + timeout: connection.timeout, + }; + + const result = await this.testConnection(testRequest); + + // 테스트 결과 저장 + await pool.query( + ` + UPDATE external_rest_api_connections + SET + last_test_date = NOW(), + last_test_result = $1, + last_test_message = $2 + WHERE id = $3 + `, + [result.success ? "Y" : "N", result.message, id] + ); + + return result; + } catch (error) { + logger.error("REST API 연결 테스트 (ID) 오류:", error); + return { + success: false, + message: "연결 테스트에 실패했습니다.", + error_details: + error instanceof Error ? error.message : "알 수 없는 오류", + }; + } + } + + /** + * 민감 정보 암호화 + */ + private static encryptSensitiveData(authConfig: any): any { + if (!authConfig) return null; + + const encrypted = { ...authConfig }; + + // 암호화 대상 필드 + if (encrypted.keyValue) { + encrypted.keyValue = this.encrypt(encrypted.keyValue); + } + if (encrypted.token) { + encrypted.token = this.encrypt(encrypted.token); + } + if (encrypted.password) { + encrypted.password = this.encrypt(encrypted.password); + } + if (encrypted.clientSecret) { + encrypted.clientSecret = this.encrypt(encrypted.clientSecret); + } + + return encrypted; + } + + /** + * 민감 정보 복호화 + */ + private static decryptSensitiveData(authConfig: any): any { + if (!authConfig) return null; + + const decrypted = { ...authConfig }; + + // 복호화 대상 필드 + try { + if (decrypted.keyValue) { + decrypted.keyValue = this.decrypt(decrypted.keyValue); + } + if (decrypted.token) { + decrypted.token = this.decrypt(decrypted.token); + } + if (decrypted.password) { + decrypted.password = this.decrypt(decrypted.password); + } + if (decrypted.clientSecret) { + decrypted.clientSecret = this.decrypt(decrypted.clientSecret); + } + } catch (error) { + logger.warn("민감 정보 복호화 실패 (암호화되지 않은 데이터일 수 있음)"); + } + + return decrypted; + } + + /** + * 암호화 헬퍼 + */ + private static encrypt(text: string): string { + const iv = crypto.randomBytes(16); + const key = crypto.scryptSync(ENCRYPTION_KEY, "salt", 32); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + + let encrypted = cipher.update(text, "utf8", "hex"); + encrypted += cipher.final("hex"); + + const authTag = cipher.getAuthTag(); + + return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`; + } + + /** + * 복호화 헬퍼 + */ + private static decrypt(text: string): string { + const parts = text.split(":"); + if (parts.length !== 3) { + // 암호화되지 않은 데이터 + return text; + } + + const iv = Buffer.from(parts[0], "hex"); + const authTag = Buffer.from(parts[1], "hex"); + const encryptedText = parts[2]; + + const key = crypto.scryptSync(ENCRYPTION_KEY, "salt", 32); + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + + let decrypted = decipher.update(encryptedText, "hex", "utf8"); + decrypted += decipher.final("utf8"); + + return decrypted; + } + + /** + * 연결 데이터 유효성 검증 + */ + private static validateConnectionData(data: ExternalRestApiConnection): void { + if (!data.connection_name || data.connection_name.trim() === "") { + throw new Error("연결명은 필수입니다."); + } + + if (!data.base_url || data.base_url.trim() === "") { + throw new Error("기본 URL은 필수입니다."); + } + + // URL 형식 검증 + try { + new URL(data.base_url); + } catch { + throw new Error("올바른 URL 형식이 아닙니다."); + } + + // 인증 타입 검증 + const validAuthTypes: AuthType[] = [ + "none", + "api-key", + "bearer", + "basic", + "oauth2", + ]; + if (!validAuthTypes.includes(data.auth_type)) { + throw new Error("올바르지 않은 인증 타입입니다."); + } + } +} diff --git a/backend-node/src/services/flowDataMoveService.ts b/backend-node/src/services/flowDataMoveService.ts index 9ed99548..39ab6013 100644 --- a/backend-node/src/services/flowDataMoveService.ts +++ b/backend-node/src/services/flowDataMoveService.ts @@ -6,10 +6,25 @@ */ import db from "../database/db"; -import { FlowAuditLog, FlowIntegrationContext } from "../types/flow"; +import { + FlowAuditLog, + FlowIntegrationContext, + FlowDefinition, +} from "../types/flow"; import { FlowDefinitionService } from "./flowDefinitionService"; import { FlowStepService } from "./flowStepService"; import { FlowExternalDbIntegrationService } from "./flowExternalDbIntegrationService"; +import { + getExternalPool, + executeExternalQuery, + executeExternalTransaction, +} from "./externalDbHelper"; +import { + getPlaceholder, + buildUpdateQuery, + buildInsertQuery, + buildSelectQuery, +} from "./dbQueryBuilder"; export class FlowDataMoveService { private flowDefinitionService: FlowDefinitionService; @@ -33,6 +48,28 @@ export class FlowDataMoveService { userId: string = "system", additionalData?: Record ): Promise<{ success: boolean; targetDataId?: any; message?: string }> { + // 0. 플로우 정의 조회 (DB 소스 확인) + const flowDefinition = await this.flowDefinitionService.findById(flowId); + if (!flowDefinition) { + throw new Error(`플로우를 찾을 수 없습니다 (ID: ${flowId})`); + } + + // 외부 DB인 경우 별도 처리 + if ( + flowDefinition.dbSourceType === "external" && + flowDefinition.dbConnectionId + ) { + return await this.moveDataToStepExternal( + flowDefinition.dbConnectionId, + fromStepId, + toStepId, + dataId, + userId, + additionalData + ); + } + + // 내부 DB 처리 (기존 로직) return await db.transaction(async (client) => { try { // 1. 단계 정보 조회 @@ -124,6 +161,28 @@ export class FlowDataMoveService { } // 5. 감사 로그 기록 + let dbConnectionName = null; + if ( + flowDefinition.dbSourceType === "external" && + flowDefinition.dbConnectionId + ) { + // 외부 DB인 경우 연결 이름 조회 + try { + const connResult = await client.query( + `SELECT connection_name FROM external_db_connections WHERE id = $1`, + [flowDefinition.dbConnectionId] + ); + if (connResult.rows && connResult.rows.length > 0) { + dbConnectionName = connResult.rows[0].connection_name; + } + } catch (error) { + console.warn("외부 DB 연결 이름 조회 실패:", error); + } + } else { + // 내부 DB인 경우 + dbConnectionName = "내부 데이터베이스"; + } + await this.logDataMove(client, { flowId, fromStepId, @@ -136,6 +195,11 @@ export class FlowDataMoveService { statusFrom: fromStep.statusValue, statusTo: toStep.statusValue, userId, + dbConnectionId: + flowDefinition.dbSourceType === "external" + ? flowDefinition.dbConnectionId + : null, + dbConnectionName, }); return { @@ -160,7 +224,14 @@ export class FlowDataMoveService { dataId: any, additionalData?: Record ): Promise { - const statusColumn = toStep.statusColumn || "flow_status"; + // 상태 컬럼이 지정되지 않은 경우 에러 + if (!toStep.statusColumn) { + throw new Error( + `단계 "${toStep.stepName}"의 상태 컬럼이 지정되지 않았습니다. 플로우 편집 화면에서 "상태 컬럼명"을 설정해주세요.` + ); + } + + const statusColumn = toStep.statusColumn; const tableName = fromStep.tableName; // 추가 필드 업데이트 준비 @@ -317,8 +388,9 @@ export class FlowDataMoveService { move_type, source_table, target_table, source_data_id, target_data_id, status_from, status_to, - changed_by, note - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + changed_by, note, + db_connection_id, db_connection_name + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) `; await client.query(query, [ @@ -334,6 +406,8 @@ export class FlowDataMoveService { params.statusTo, params.userId, params.note || null, + params.dbConnectionId || null, + params.dbConnectionName || null, ]); } @@ -408,6 +482,8 @@ export class FlowDataMoveService { targetDataId: row.target_data_id, statusFrom: row.status_from, statusTo: row.status_to, + dbConnectionId: row.db_connection_id, + dbConnectionName: row.db_connection_name, })); } @@ -452,6 +528,8 @@ export class FlowDataMoveService { targetDataId: row.target_data_id, statusFrom: row.status_from, statusTo: row.status_to, + dbConnectionId: row.db_connection_id, + dbConnectionName: row.db_connection_name, })); } @@ -590,4 +668,324 @@ export class FlowDataMoveService { userId, ]); } + + /** + * 외부 DB 데이터 이동 처리 + */ + private async moveDataToStepExternal( + dbConnectionId: number, + fromStepId: number, + toStepId: number, + dataId: any, + userId: string = "system", + additionalData?: Record + ): Promise<{ success: boolean; targetDataId?: any; message?: string }> { + return await executeExternalTransaction( + dbConnectionId, + async (externalClient, dbType) => { + try { + // 1. 단계 정보 조회 (내부 DB에서) + const fromStep = await this.flowStepService.findById(fromStepId); + const toStep = await this.flowStepService.findById(toStepId); + + if (!fromStep || !toStep) { + throw new Error("유효하지 않은 단계입니다"); + } + + let targetDataId = dataId; + let sourceTable = fromStep.tableName; + let targetTable = toStep.tableName || fromStep.tableName; + + // 2. 이동 방식에 따라 처리 + switch (toStep.moveType || "status") { + case "status": + // 상태 변경 방식 + await this.moveByStatusChangeExternal( + externalClient, + dbType, + fromStep, + toStep, + dataId, + additionalData + ); + break; + + case "table": + // 테이블 이동 방식 + targetDataId = await this.moveByTableTransferExternal( + externalClient, + dbType, + fromStep, + toStep, + dataId, + additionalData + ); + targetTable = toStep.targetTable || toStep.tableName; + break; + + case "both": + // 하이브리드 방식: 둘 다 수행 + await this.moveByStatusChangeExternal( + externalClient, + dbType, + fromStep, + toStep, + dataId, + additionalData + ); + targetDataId = await this.moveByTableTransferExternal( + externalClient, + dbType, + fromStep, + toStep, + dataId, + additionalData + ); + targetTable = toStep.targetTable || toStep.tableName; + break; + + default: + throw new Error( + `지원하지 않는 이동 방식입니다: ${toStep.moveType}` + ); + } + + // 3. 외부 연동 처리는 생략 (외부 DB 자체가 외부이므로) + + // 4. 외부 DB 연결 이름 조회 + let dbConnectionName = null; + try { + const connResult = await db.query( + `SELECT connection_name FROM external_db_connections WHERE id = $1`, + [dbConnectionId] + ); + if (connResult.length > 0) { + dbConnectionName = connResult[0].connection_name; + } + } catch (error) { + console.warn("외부 DB 연결 이름 조회 실패:", error); + } + + // 5. 감사 로그 기록 (내부 DB에) + // 외부 DB는 내부 DB 트랜잭션 외부이므로 직접 쿼리 실행 + const auditQuery = ` + INSERT INTO flow_audit_log ( + flow_definition_id, from_step_id, to_step_id, + move_type, source_table, target_table, + source_data_id, target_data_id, + status_from, status_to, + changed_by, note, + db_connection_id, db_connection_name + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + `; + + await db.query(auditQuery, [ + toStep.flowDefinitionId, + fromStep.id, + toStep.id, + toStep.moveType || "status", + sourceTable, + targetTable, + dataId, + targetDataId, + null, // statusFrom + toStep.statusValue || null, // statusTo + userId, + `외부 DB (${dbType}) 데이터 이동`, + dbConnectionId, + dbConnectionName, + ]); + + return { + success: true, + targetDataId, + message: `데이터 이동이 완료되었습니다 (외부 DB: ${dbType})`, + }; + } catch (error: any) { + console.error("외부 DB 데이터 이동 오류:", error); + throw error; + } + } + ); + } + + /** + * 외부 DB 상태 변경 방식으로 데이터 이동 + */ + private async moveByStatusChangeExternal( + externalClient: any, + dbType: string, + fromStep: any, + toStep: any, + dataId: any, + additionalData?: Record + ): Promise { + // 상태 컬럼이 지정되지 않은 경우 에러 + if (!toStep.statusColumn) { + throw new Error( + `단계 "${toStep.stepName}"의 상태 컬럼이 지정되지 않았습니다. 플로우 편집 화면에서 "상태 컬럼명"을 설정해주세요.` + ); + } + + const statusColumn = toStep.statusColumn; + const tableName = fromStep.tableName; + const normalizedDbType = dbType.toLowerCase(); + + // 업데이트할 필드 준비 + const updateFields: { column: string; value: any }[] = [ + { column: statusColumn, value: toStep.statusValue }, + ]; + + // 추가 데이터가 있으면 함께 업데이트 + if (additionalData) { + for (const [key, value] of Object.entries(additionalData)) { + updateFields.push({ column: key, value }); + } + } + + // DB별 쿼리 생성 + const { query: updateQuery, values } = buildUpdateQuery( + dbType, + tableName, + updateFields, + "id" + ); + + // WHERE 절 값 설정 (마지막 파라미터) + values[values.length - 1] = dataId; + + // 쿼리 실행 (DB 타입별 처리) + let result: any; + if (normalizedDbType === "postgresql") { + result = await externalClient.query(updateQuery, values); + } else if (normalizedDbType === "mysql" || normalizedDbType === "mariadb") { + [result] = await externalClient.query(updateQuery, values); + } else if (normalizedDbType === "mssql") { + const request = externalClient.request(); + values.forEach((val: any, idx: number) => { + request.input(`p${idx + 1}`, val); + }); + result = await request.query(updateQuery); + } else if (normalizedDbType === "oracle") { + result = await externalClient.execute(updateQuery, values, { + autoCommit: false, + }); + } + + // 결과 확인 + const affectedRows = + normalizedDbType === "postgresql" + ? result.rowCount + : normalizedDbType === "mssql" + ? result.rowsAffected[0] + : normalizedDbType === "oracle" + ? result.rowsAffected + : result.affectedRows; + + if (affectedRows === 0) { + throw new Error(`데이터를 찾을 수 없습니다: ${dataId}`); + } + } + + /** + * 외부 DB 테이블 이동 방식으로 데이터 이동 + */ + private async moveByTableTransferExternal( + externalClient: any, + dbType: string, + fromStep: any, + toStep: any, + dataId: any, + additionalData?: Record + ): Promise { + const sourceTable = fromStep.tableName; + const targetTable = toStep.targetTable || toStep.tableName; + const fieldMappings = toStep.fieldMappings || {}; + const normalizedDbType = dbType.toLowerCase(); + + // 1. 소스 데이터 조회 + const { query: selectQuery, placeholder } = buildSelectQuery( + dbType, + sourceTable, + "id" + ); + + let sourceResult: any; + if (normalizedDbType === "postgresql") { + sourceResult = await externalClient.query(selectQuery, [dataId]); + } else if (normalizedDbType === "mysql" || normalizedDbType === "mariadb") { + [sourceResult] = await externalClient.query(selectQuery, [dataId]); + } else if (normalizedDbType === "mssql") { + const request = externalClient.request(); + request.input("p1", dataId); + sourceResult = await request.query(selectQuery); + sourceResult = { rows: sourceResult.recordset }; + } else if (normalizedDbType === "oracle") { + sourceResult = await externalClient.execute(selectQuery, [dataId], { + autoCommit: false, + outFormat: 4001, // oracledb.OUT_FORMAT_OBJECT + }); + } + + const rows = sourceResult.rows || sourceResult; + if (!rows || rows.length === 0) { + throw new Error(`소스 데이터를 찾을 수 없습니다: ${dataId}`); + } + + const sourceData = rows[0]; + + // 2. 필드 매핑 적용 + const targetData: Record = {}; + + for (const [targetField, sourceField] of Object.entries(fieldMappings)) { + const sourceFieldKey = sourceField as string; + if (sourceData[sourceFieldKey] !== undefined) { + targetData[targetField] = sourceData[sourceFieldKey]; + } + } + + // 추가 데이터 병합 + if (additionalData) { + Object.assign(targetData, additionalData); + } + + // 3. 대상 테이블에 삽입 + const { query: insertQuery, values } = buildInsertQuery( + dbType, + targetTable, + targetData + ); + + let insertResult: any; + let newDataId: any; + + if (normalizedDbType === "postgresql") { + insertResult = await externalClient.query(insertQuery, values); + newDataId = insertResult.rows[0].id; + } else if (normalizedDbType === "mysql" || normalizedDbType === "mariadb") { + [insertResult] = await externalClient.query(insertQuery, values); + newDataId = insertResult.insertId; + } else if (normalizedDbType === "mssql") { + const request = externalClient.request(); + values.forEach((val: any, idx: number) => { + request.input(`p${idx + 1}`, val); + }); + insertResult = await request.query(insertQuery); + newDataId = insertResult.recordset[0].id; + } else if (normalizedDbType === "oracle") { + // Oracle RETURNING 절 처리 + const outBinds: any = { id: { dir: 3003, type: 2001 } }; // OUT, NUMBER + insertResult = await externalClient.execute(insertQuery, values, { + autoCommit: false, + outBinds: outBinds, + }); + newDataId = insertResult.outBinds.id[0]; + } + + // 4. 필요 시 소스 데이터 삭제 (옵션) + // const deletePlaceholder = getPlaceholder(dbType, 1); + // await externalClient.query(`DELETE FROM ${sourceTable} WHERE id = ${deletePlaceholder}`, [dataId]); + + return newDataId; + } } diff --git a/backend-node/src/services/flowDefinitionService.ts b/backend-node/src/services/flowDefinitionService.ts index 859e0792..f08f934d 100644 --- a/backend-node/src/services/flowDefinitionService.ts +++ b/backend-node/src/services/flowDefinitionService.ts @@ -17,18 +17,33 @@ export class FlowDefinitionService { request: CreateFlowDefinitionRequest, userId: string ): Promise { + console.log("🔥 flowDefinitionService.create called with:", { + name: request.name, + description: request.description, + tableName: request.tableName, + dbSourceType: request.dbSourceType, + dbConnectionId: request.dbConnectionId, + userId, + }); + const query = ` - INSERT INTO flow_definition (name, description, table_name, created_by) - VALUES ($1, $2, $3, $4) + INSERT INTO flow_definition (name, description, table_name, db_source_type, db_connection_id, created_by) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING * `; - const result = await db.query(query, [ + const values = [ request.name, request.description || null, - request.tableName, + request.tableName || null, + request.dbSourceType || "internal", + request.dbConnectionId || null, userId, - ]); + ]; + + console.log("💾 Executing INSERT with values:", values); + + const result = await db.query(query, values); return this.mapToFlowDefinition(result[0]); } @@ -162,6 +177,8 @@ export class FlowDefinitionService { name: row.name, description: row.description, tableName: row.table_name, + dbSourceType: row.db_source_type || "internal", + dbConnectionId: row.db_connection_id, isActive: row.is_active, createdBy: row.created_by, createdAt: row.created_at, diff --git a/backend-node/src/services/flowExecutionService.ts b/backend-node/src/services/flowExecutionService.ts index ae4f1369..9d9eb9c4 100644 --- a/backend-node/src/services/flowExecutionService.ts +++ b/backend-node/src/services/flowExecutionService.ts @@ -8,6 +8,8 @@ import { FlowStepDataCount, FlowStepDataList } from "../types/flow"; import { FlowDefinitionService } from "./flowDefinitionService"; import { FlowStepService } from "./flowStepService"; import { FlowConditionParser } from "./flowConditionParser"; +import { executeExternalQuery } from "./externalDbHelper"; +import { getPlaceholder, buildPaginationClause } from "./dbQueryBuilder"; export class FlowExecutionService { private flowDefinitionService: FlowDefinitionService; @@ -28,6 +30,13 @@ export class FlowExecutionService { throw new Error(`Flow definition not found: ${flowId}`); } + console.log("🔍 [getStepDataCount] Flow Definition:", { + flowId, + dbSourceType: flowDef.dbSourceType, + dbConnectionId: flowDef.dbConnectionId, + tableName: flowDef.tableName, + }); + // 2. 플로우 단계 조회 const step = await this.flowStepService.findById(stepId); if (!step) { @@ -46,11 +55,40 @@ export class FlowExecutionService { step.conditionJson ); - // 5. 카운트 쿼리 실행 + // 5. 카운트 쿼리 실행 (내부 또는 외부 DB) const query = `SELECT COUNT(*) as count FROM ${tableName} WHERE ${where}`; - const result = await db.query(query, params); - return parseInt(result[0].count); + console.log("🔍 [getStepDataCount] Query Info:", { + tableName, + query, + params, + isExternal: flowDef.dbSourceType === "external", + connectionId: flowDef.dbConnectionId, + }); + + let result: any; + if (flowDef.dbSourceType === "external" && flowDef.dbConnectionId) { + // 외부 DB 조회 + console.log( + "✅ [getStepDataCount] Using EXTERNAL DB:", + flowDef.dbConnectionId + ); + const externalResult = await executeExternalQuery( + flowDef.dbConnectionId, + query, + params + ); + console.log("📦 [getStepDataCount] External result:", externalResult); + result = externalResult.rows; + } else { + // 내부 DB 조회 + console.log("✅ [getStepDataCount] Using INTERNAL DB"); + result = await db.query(query, params); + } + + const count = parseInt(result[0].count || result[0].COUNT); + console.log("✅ [getStepDataCount] Final count:", count); + return count; } /** @@ -88,47 +126,98 @@ export class FlowExecutionService { const offset = (page - 1) * pageSize; + const isExternalDb = + flowDef.dbSourceType === "external" && flowDef.dbConnectionId; + // 5. 전체 카운트 const countQuery = `SELECT COUNT(*) as count FROM ${tableName} WHERE ${where}`; - const countResult = await db.query(countQuery, params); - const total = parseInt(countResult[0].count); + let countResult: any; + let total: number; - // 6. 테이블의 Primary Key 컬럼 찾기 - let orderByColumn = ""; - try { - const pkQuery = ` - SELECT a.attname - FROM pg_index i - JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) - WHERE i.indrelid = $1::regclass - AND i.indisprimary - LIMIT 1 - `; - const pkResult = await db.query(pkQuery, [tableName]); - if (pkResult.length > 0) { - orderByColumn = pkResult[0].attname; - } - } catch (err) { - // Primary Key를 찾지 못하면 ORDER BY 없이 진행 - console.warn(`Could not find primary key for table ${tableName}:`, err); + if (isExternalDb) { + const externalCountResult = await executeExternalQuery( + flowDef.dbConnectionId!, + countQuery, + params + ); + countResult = externalCountResult.rows; + total = parseInt(countResult[0].count || countResult[0].COUNT); + } else { + countResult = await db.query(countQuery, params); + total = parseInt(countResult[0].count); } - // 7. 데이터 조회 - const orderByClause = orderByColumn ? `ORDER BY ${orderByColumn} DESC` : ""; - const dataQuery = ` - SELECT * FROM ${tableName} - WHERE ${where} - ${orderByClause} - LIMIT $${params.length + 1} OFFSET $${params.length + 2} - `; - const dataResult = await db.query(dataQuery, [...params, pageSize, offset]); + // 6. 데이터 조회 (DB 타입별 페이징 처리) + let dataQuery: string; + let dataParams: any[]; - return { - records: dataResult, - total, - page, - pageSize, - }; + if (isExternalDb) { + // 외부 DB는 id 컬럼으로 정렬 (가정) + // DB 타입에 따른 페이징 절은 빌더에서 처리하지 않고 직접 작성 + // PostgreSQL, MySQL, MSSQL, Oracle 모두 지원하도록 단순화 + dataQuery = ` + SELECT * FROM ${tableName} + WHERE ${where} + ORDER BY id DESC + LIMIT ${pageSize} OFFSET ${offset} + `; + dataParams = params; + + const externalDataResult = await executeExternalQuery( + flowDef.dbConnectionId!, + dataQuery, + dataParams + ); + + return { + records: externalDataResult.rows, + total, + page, + pageSize, + }; + } else { + // 내부 DB (PostgreSQL) + // Primary Key 컬럼 찾기 + let orderByColumn = ""; + try { + const pkQuery = ` + SELECT a.attname + FROM pg_index i + JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) + WHERE i.indrelid = $1::regclass + AND i.indisprimary + LIMIT 1 + `; + const pkResult = await db.query(pkQuery, [tableName]); + if (pkResult.length > 0) { + orderByColumn = pkResult[0].attname; + } + } catch (err) { + console.warn(`Could not find primary key for table ${tableName}:`, err); + } + + const orderByClause = orderByColumn + ? `ORDER BY ${orderByColumn} DESC` + : ""; + dataQuery = ` + SELECT * FROM ${tableName} + WHERE ${where} + ${orderByClause} + LIMIT $${params.length + 1} OFFSET $${params.length + 2} + `; + const dataResult = await db.query(dataQuery, [ + ...params, + pageSize, + offset, + ]); + + return { + records: dataResult, + total, + page, + pageSize, + }; + } } /** diff --git a/backend-node/src/services/mailReceiveBasicService.ts b/backend-node/src/services/mailReceiveBasicService.ts index d5e3a78f..6bec4d93 100644 --- a/backend-node/src/services/mailReceiveBasicService.ts +++ b/backend-node/src/services/mailReceiveBasicService.ts @@ -88,6 +88,9 @@ export class MailReceiveBasicService { port: config.port, tls: config.tls, tlsOptions: { rejectUnauthorized: false }, + authTimeout: 30000, // 인증 타임아웃 30초 + connTimeout: 30000, // 연결 타임아웃 30초 + keepalive: true, }); } @@ -116,7 +119,7 @@ export class MailReceiveBasicService { tls: true, }; - // console.log(`📧 IMAP 연결 시도 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, 이메일: ${imapConfig.user}`); + // // console.log(`📧 IMAP 연결 시도 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, 이메일: ${imapConfig.user}`); return new Promise((resolve, reject) => { const imap = this.createImapConnection(imapConfig); @@ -130,7 +133,7 @@ export class MailReceiveBasicService { }, 30000); imap.once("ready", () => { - // console.log('✅ IMAP 연결 성공! INBOX 열기 시도...'); + // // console.log('✅ IMAP 연결 성공! INBOX 열기 시도...'); clearTimeout(timeout); imap.openBox("INBOX", true, (err: any, box: any) => { @@ -140,10 +143,10 @@ export class MailReceiveBasicService { return reject(err); } - // console.log(`📬 INBOX 열림 - 전체 메일 수: ${box.messages.total}`); + // // console.log(`📬 INBOX 열림 - 전체 메일 수: ${box.messages.total}`); const totalMessages = box.messages.total; if (totalMessages === 0) { - // console.log('📭 메일함이 비어있습니다'); + // // console.log('📭 메일함이 비어있습니다'); imap.end(); return resolve([]); } @@ -152,19 +155,19 @@ export class MailReceiveBasicService { const start = Math.max(1, totalMessages - limit + 1); const end = totalMessages; - // console.log(`📨 메일 가져오기 시작 - 범위: ${start}~${end}`); + // // console.log(`📨 메일 가져오기 시작 - 범위: ${start}~${end}`); const fetch = imap.seq.fetch(`${start}:${end}`, { bodies: ["HEADER", "TEXT"], struct: true, }); - // console.log(`📦 fetch 객체 생성 완료`); + // // console.log(`📦 fetch 객체 생성 완료`); let processedCount = 0; const totalToProcess = end - start + 1; fetch.on("message", (msg: any, seqno: any) => { - // console.log(`📬 메일 #${seqno} 처리 시작`); + // // console.log(`📬 메일 #${seqno} 처리 시작`); let header: string = ""; let body: string = ""; let attributes: any = null; @@ -222,7 +225,7 @@ export class MailReceiveBasicService { }; mails.push(mail); - // console.log(`✓ 메일 #${seqno} 파싱 완료 (${mails.length}/${totalToProcess})`); + // // console.log(`✓ 메일 #${seqno} 파싱 완료 (${mails.length}/${totalToProcess})`); processedCount++; } catch (parseError) { // console.error(`메일 #${seqno} 파싱 오류:`, parseError); @@ -240,18 +243,18 @@ export class MailReceiveBasicService { }); fetch.once("end", () => { - // console.log(`📭 fetch 종료 - 처리 완료 대기 중... (현재: ${mails.length}개)`); + // // console.log(`📭 fetch 종료 - 처리 완료 대기 중... (현재: ${mails.length}개)`); // 모든 메일 처리가 완료될 때까지 대기 const checkComplete = setInterval(() => { - // console.log(`⏳ 대기 중 - 처리됨: ${processedCount}/${totalToProcess}, 메일: ${mails.length}개`); + // // console.log(`⏳ 대기 중 - 처리됨: ${processedCount}/${totalToProcess}, 메일: ${mails.length}개`); if (processedCount >= totalToProcess) { clearInterval(checkComplete); - // console.log(`✅ 메일 가져오기 완료 - 총 ${mails.length}개`); + // // console.log(`✅ 메일 가져오기 완료 - 총 ${mails.length}개`); imap.end(); // 최신 메일이 위로 오도록 정렬 mails.sort((a, b) => b.date.getTime() - a.date.getTime()); - // console.log(`📤 메일 목록 반환: ${mails.length}개`); + // // console.log(`📤 메일 목록 반환: ${mails.length}개`); resolve(mails); } }, 100); @@ -259,7 +262,7 @@ export class MailReceiveBasicService { // 최대 10초 대기 setTimeout(() => { clearInterval(checkComplete); - // console.log(`⚠️ 타임아웃 - 부분 반환: ${mails.length}/${totalToProcess}개`); + // // console.log(`⚠️ 타임아웃 - 부분 반환: ${mails.length}/${totalToProcess}개`); imap.end(); mails.sort((a, b) => b.date.getTime() - a.date.getTime()); resolve(mails); @@ -275,10 +278,10 @@ export class MailReceiveBasicService { }); imap.once("end", () => { - // console.log('🔌 IMAP 연결 종료'); + // // console.log('🔌 IMAP 연결 종료'); }); - // console.log('🔗 IMAP.connect() 호출...'); + // // console.log('🔗 IMAP.connect() 호출...'); imap.connect(); }); } @@ -329,9 +332,9 @@ export class MailReceiveBasicService { return reject(err); } - console.log( - `📬 INBOX 정보 - 전체 메일: ${box.messages.total}, 요청한 seqno: ${seqno}` - ); + // console.log( + // `📬 INBOX 정보 - 전체 메일: ${box.messages.total}, 요청한 seqno: ${seqno}` + // ); if (seqno > box.messages.total || seqno < 1) { console.error( @@ -350,21 +353,21 @@ export class MailReceiveBasicService { let parsingComplete = false; fetch.on("message", (msg: any, seqnum: any) => { - console.log(`📨 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`); + // console.log(`📨 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`); msg.on("body", (stream: any, info: any) => { - console.log(`📝 메일 본문 스트림 시작 - which: ${info.which}`); + // console.log(`📝 메일 본문 스트림 시작 - which: ${info.which}`); let buffer = ""; stream.on("data", (chunk: any) => { buffer += chunk.toString("utf8"); }); stream.once("end", async () => { - console.log( - `✅ 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}` - ); + // console.log( + // `✅ 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}` + // ); try { const parsed = await simpleParser(buffer); - console.log(`✅ 메일 파싱 완료 - 제목: ${parsed.subject}`); + // console.log(`✅ 메일 파싱 완료 - 제목: ${parsed.subject}`); const fromAddress = Array.isArray(parsed.from) ? parsed.from[0] @@ -412,7 +415,7 @@ export class MailReceiveBasicService { // msg 전체가 처리되었을 때 이벤트 msg.once("end", () => { - console.log(`📮 메일 메시지 처리 완료 - seqnum: ${seqnum}`); + // console.log(`📮 메일 메시지 처리 완료 - seqnum: ${seqnum}`); }); }); @@ -423,15 +426,15 @@ export class MailReceiveBasicService { }); fetch.once("end", () => { - console.log(`🏁 Fetch 종료 - parsingComplete: ${parsingComplete}`); + // console.log(`🏁 Fetch 종료 - parsingComplete: ${parsingComplete}`); // 비동기 파싱이 완료될 때까지 대기 const waitForParsing = setInterval(() => { if (parsingComplete) { clearInterval(waitForParsing); - console.log( - `✅ 파싱 완료 대기 종료 - mailDetail이 ${mailDetail ? "존재함" : "null"}` - ); + // console.log( + // `✅ 파싱 완료 대기 종료 - mailDetail이 ${mailDetail ? "존재함" : "null"}` + // ); imap.end(); resolve(mailDetail); } @@ -474,29 +477,47 @@ export class MailReceiveBasicService { const decryptedPassword = encryptionService.decrypt(account.smtpPassword); const accountAny = account as any; + const imapPort = accountAny.imapPort || this.inferImapPort(account.smtpPort); + const imapConfig: ImapConfig = { user: account.email, password: decryptedPassword, host: accountAny.imapHost || account.smtpHost, - port: this.inferImapPort(account.smtpPort, accountAny.imapPort), - tls: true, + port: imapPort, + tls: imapPort === 993, // 993 포트면 TLS 사용 }; return new Promise((resolve, reject) => { const imap = this.createImapConnection(imapConfig); + // 타임아웃 설정 + const timeout = setTimeout(() => { + console.error('❌ IMAP 읽음 표시 타임아웃 (30초)'); + imap.end(); + reject(new Error("IMAP 연결 타임아웃")); + }, 30000); + imap.once("ready", () => { + clearTimeout(timeout); + // console.log(`🔗 IMAP 연결 성공 - 읽음 표시 시작 (seqno=${seqno})`); + + // false로 변경: 쓰기 가능 모드로 INBOX 열기 imap.openBox("INBOX", false, (err: any, box: any) => { if (err) { + console.error('❌ INBOX 열기 실패:', err); imap.end(); return reject(err); } + // console.log(`📬 INBOX 열림 (쓰기 가능 모드)`); + imap.seq.addFlags(seqno, ["\\Seen"], (flagErr: any) => { imap.end(); if (flagErr) { + console.error("❌ 읽음 플래그 설정 실패:", flagErr); reject(flagErr); } else { + // console.log("✅ 읽음 플래그 설정 성공 - seqno:", seqno); resolve({ success: true, message: "메일을 읽음으로 표시했습니다.", @@ -507,9 +528,16 @@ export class MailReceiveBasicService { }); imap.once("error", (imapErr: any) => { + clearTimeout(timeout); + console.error('❌ IMAP 에러:', imapErr); reject(imapErr); }); + imap.once("end", () => { + clearTimeout(timeout); + }); + + // console.log(`🔌 IMAP 연결 시도 중... (host=${imapConfig.host}, port=${imapConfig.port})`); imap.connect(); }); } @@ -528,7 +556,7 @@ export class MailReceiveBasicService { // 비밀번호 복호화 const decryptedPassword = encryptionService.decrypt(account.smtpPassword); - // console.log(`🔐 IMAP 테스트 - 이메일: ${account.email}, 비밀번호 길이: ${decryptedPassword.length}`); + // // console.log(`🔐 IMAP 테스트 - 이메일: ${account.email}, 비밀번호 길이: ${decryptedPassword.length}`); const accountAny = account as any; const imapConfig: ImapConfig = { @@ -538,7 +566,7 @@ export class MailReceiveBasicService { port: this.inferImapPort(account.smtpPort, accountAny.imapPort), tls: true, }; - // console.log(`📧 IMAP 설정 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, TLS: ${imapConfig.tls}`); + // // console.log(`📧 IMAP 설정 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, TLS: ${imapConfig.tls}`); return new Promise((resolve, reject) => { const imap = this.createImapConnection(imapConfig); @@ -664,32 +692,32 @@ export class MailReceiveBasicService { let parsingComplete = false; fetch.on("message", (msg: any, seqnum: any) => { - console.log(`📎 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`); + // console.log(`📎 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`); msg.on("body", (stream: any, info: any) => { - console.log(`📎 메일 본문 스트림 시작`); + // console.log(`📎 메일 본문 스트림 시작`); let buffer = ""; stream.on("data", (chunk: any) => { buffer += chunk.toString("utf8"); }); stream.once("end", async () => { - console.log( - `📎 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}` - ); + // console.log( + // `📎 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}` + // ); try { const parsed = await simpleParser(buffer); - console.log( - `📎 파싱 완료 - 첨부파일 개수: ${parsed.attachments?.length || 0}` - ); + // console.log( + // `📎 파싱 완료 - 첨부파일 개수: ${parsed.attachments?.length || 0}` + // ); if ( parsed.attachments && parsed.attachments[attachmentIndex] ) { const attachment = parsed.attachments[attachmentIndex]; - console.log( - `📎 첨부파일 발견 (index ${attachmentIndex}): ${attachment.filename}` - ); + // console.log( + // `📎 첨부파일 발견 (index ${attachmentIndex}): ${attachment.filename}` + // ); // 안전한 파일명 생성 const safeFilename = this.sanitizeFilename( @@ -701,7 +729,7 @@ export class MailReceiveBasicService { // 파일 저장 await fs.writeFile(filePath, attachment.content); - console.log(`📎 파일 저장 완료: ${filePath}`); + // console.log(`📎 파일 저장 완료: ${filePath}`); attachmentResult = { filePath, @@ -711,9 +739,9 @@ export class MailReceiveBasicService { }; parsingComplete = true; } else { - console.log( - `❌ 첨부파일 index ${attachmentIndex}를 찾을 수 없음 (총 ${parsed.attachments?.length || 0}개)` - ); + // console.log( + // `❌ 첨부파일 index ${attachmentIndex}를 찾을 수 없음 (총 ${parsed.attachments?.length || 0}개)` + // ); parsingComplete = true; } } catch (parseError) { @@ -731,14 +759,14 @@ export class MailReceiveBasicService { }); fetch.once("end", () => { - console.log('📎 fetch.once("end") 호출됨 - 파싱 완료 대기 시작...'); + // console.log('📎 fetch.once("end") 호출됨 - 파싱 완료 대기 시작...'); // 파싱 완료를 기다림 (최대 5초) const checkComplete = setInterval(() => { if (parsingComplete) { - console.log( - `✅ 파싱 완료 확인 - attachmentResult: ${attachmentResult ? "있음" : "없음"}` - ); + // console.log( + // `✅ 파싱 완료 확인 - attachmentResult: ${attachmentResult ? "있음" : "없음"}` + // ); clearInterval(checkComplete); imap.end(); resolve(attachmentResult); @@ -747,9 +775,9 @@ export class MailReceiveBasicService { setTimeout(() => { clearInterval(checkComplete); - console.log( - `⚠️ 타임아웃 - attachmentResult: ${attachmentResult ? "있음" : "없음"}` - ); + // console.log( + // `⚠️ 타임아웃 - attachmentResult: ${attachmentResult ? "있음" : "없음"}` + // ); imap.end(); resolve(attachmentResult); }, 5000); @@ -774,4 +802,96 @@ export class MailReceiveBasicService { .replace(/_{2,}/g, "_") .substring(0, 200); // 최대 길이 제한 } + + /** + * IMAP 서버에서 메일 삭제 (휴지통으로 이동) + */ + async deleteMail(accountId: string, seqno: number): Promise<{ success: boolean; message: string }> { + const account = await mailAccountFileService.getAccountById(accountId); + + if (!account) { + throw new Error("메일 계정을 찾을 수 없습니다."); + } + + // 비밀번호 복호화 + const decryptedPassword = encryptionService.decrypt(account.smtpPassword); + + // IMAP 설정 (타입 캐스팅) + const accountAny = account as any; + const imapPort = accountAny.imapPort || this.inferImapPort(account.smtpPort); + + const config: ImapConfig = { + user: account.smtpUsername || account.email, + password: decryptedPassword, + host: accountAny.imapHost || account.smtpHost, + port: imapPort, + tls: imapPort === 993, // 993 포트면 TLS 사용, 143이면 사용 안함 + }; + + return new Promise((resolve, reject) => { + const imap = this.createImapConnection(config); + + // 30초 타임아웃 설정 + const timeout = setTimeout(() => { + console.error('❌ IMAP 메일 삭제 타임아웃 (30초)'); + imap.end(); + reject(new Error("IMAP 연결 타임아웃")); + }, 30000); + + imap.once("ready", () => { + clearTimeout(timeout); + // console.log(`🔗 IMAP 연결 성공 - 메일 삭제 시작 (seqno=${seqno})`); + + imap.openBox("INBOX", false, (err: any) => { + if (err) { + console.error('❌ INBOX 열기 실패:', err); + imap.end(); + return reject(err); + } + + // 메일을 삭제 플래그로 표시 (seq.addFlags 사용) + imap.seq.addFlags(seqno, ["\\Deleted"], (flagErr: any) => { + if (flagErr) { + console.error('❌ 삭제 플래그 추가 실패:', flagErr); + imap.end(); + return reject(flagErr); + } + + // console.log(`✓ 삭제 플래그 추가 완료 (seqno=${seqno})`); + + // 삭제 플래그가 표시된 메일을 영구 삭제 (실제로는 휴지통으로 이동) + imap.expunge((expungeErr: any) => { + imap.end(); + + if (expungeErr) { + console.error('❌ expunge 실패:', expungeErr); + return reject(expungeErr); + } + + // console.log(`🗑️ 메일 삭제 완료: seqno=${seqno}`); + resolve({ + success: true, + message: "메일이 삭제되었습니다.", + }); + }); + }); + }); + }); + + imap.once("error", (imapErr: any) => { + clearTimeout(timeout); + console.error('❌ IMAP 에러:', imapErr); + reject(imapErr); + }); + + imap.once("end", () => { + clearTimeout(timeout); + }); + + // console.log(`🔌 IMAP 연결 시도 중... (host=${config.host}, port=${config.port})`); + imap.connect(); + }); + } } + +export const mailReceiveBasicService = new MailReceiveBasicService(); diff --git a/backend-node/src/services/mailSendSimpleService.ts b/backend-node/src/services/mailSendSimpleService.ts index 188e68c8..b4dce503 100644 --- a/backend-node/src/services/mailSendSimpleService.ts +++ b/backend-node/src/services/mailSendSimpleService.ts @@ -34,6 +34,29 @@ export interface SendMailResult { error?: string; } +export interface BulkSendRequest { + accountId: string; + templateId?: string; // 템플릿 ID (선택) + subject: string; + customHtml?: string; // 직접 작성한 HTML (선택) + recipients: Array<{ + email: string; + variables?: Record; // 템플릿 사용 시에만 필요 + }>; +} + +export interface BulkSendResult { + total: number; + success: number; + failed: number; + results: Array<{ + email: string; + success: boolean; + messageId?: string; + error?: string; + }>; +} + class MailSendSimpleService { /** * 단일 메일 발송 또는 소규모 발송 @@ -63,7 +86,7 @@ class MailSendSimpleService { // 🎯 수정된 컴포넌트가 있으면 덮어쓰기 if (request.modifiedTemplateComponents && request.modifiedTemplateComponents.length > 0) { - console.log('✏️ 수정된 템플릿 컴포넌트 사용:', request.modifiedTemplateComponents.length); + // console.log('✏️ 수정된 템플릿 컴포넌트 사용:', request.modifiedTemplateComponents.length); template.components = request.modifiedTemplateComponents; } @@ -84,15 +107,15 @@ class MailSendSimpleService { // 4. 비밀번호 복호화 const decryptedPassword = encryptionService.decrypt(account.smtpPassword); - // console.log('🔐 비밀번호 복호화 완료'); - // console.log('🔐 암호화된 비밀번호 (일부):', account.smtpPassword.substring(0, 30) + '...'); - // console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length); + // // console.log('🔐 비밀번호 복호화 완료'); + // // console.log('🔐 암호화된 비밀번호 (일부):', account.smtpPassword.substring(0, 30) + '...'); + // // console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length); // 5. SMTP 연결 생성 // 포트 465는 SSL/TLS를 사용해야 함 const isSecure = account.smtpPort === 465 ? true : (account.smtpSecure || false); - // console.log('📧 SMTP 연결 설정:', { + // // console.log('📧 SMTP 연결 설정:', { // host: account.smtpHost, // port: account.smtpPort, // secure: isSecure, @@ -112,7 +135,7 @@ class MailSendSimpleService { greetingTimeout: 30000, }); - console.log('📧 메일 발송 시도 중...'); + // console.log('📧 메일 발송 시도 중...'); // 6. 메일 발송 (CC, BCC, 첨부파일 지원) const mailOptions: any = { @@ -125,13 +148,13 @@ class MailSendSimpleService { // 참조(CC) 추가 if (request.cc && request.cc.length > 0) { mailOptions.cc = request.cc.join(', '); - // console.log('📧 참조(CC):', request.cc); + // // console.log('📧 참조(CC):', request.cc); } // 숨은참조(BCC) 추가 if (request.bcc && request.bcc.length > 0) { mailOptions.bcc = request.bcc.join(', '); - // console.log('🔒 숨은참조(BCC):', request.bcc); + // // console.log('🔒 숨은참조(BCC):', request.bcc); } // 첨부파일 추가 (한글 파일명 인코딩 처리) @@ -163,17 +186,17 @@ class MailSendSimpleService { } }; }); - console.log('📎 첨부파일 (원본):', request.attachments.map((a: any) => a.filename.replace(/^\d+-\d+_/, ''))); - console.log('📎 첨부파일 (인코딩):', mailOptions.attachments.map((a: any) => a.filename)); + // console.log('📎 첨부파일 (원본):', request.attachments.map((a: any) => a.filename.replace(/^\d+-\d+_/, ''))); + // console.log('📎 첨부파일 (인코딩):', mailOptions.attachments.map((a: any) => a.filename)); } const info = await transporter.sendMail(mailOptions); - console.log('✅ 메일 발송 성공:', { - messageId: info.messageId, - accepted: info.accepted, - rejected: info.rejected, - }); + // console.log('✅ 메일 발송 성공:', { + // messageId: info.messageId, + // accepted: info.accepted, + // rejected: info.rejected, + // }); // 발송 이력 저장 (성공) try { @@ -402,6 +425,73 @@ class MailSendSimpleService { } } + /** + * 대량 메일 발송 (배치 처리) + */ + async sendBulkMail(request: BulkSendRequest): Promise { + const results: Array<{ + email: string; + success: boolean; + messageId?: string; + error?: string; + }> = []; + + let successCount = 0; + let failedCount = 0; + + // console.log(`📧 대량 발송 시작: ${request.recipients.length}명`); + + // 순차 발송 (너무 빠르면 스팸으로 분류될 수 있음) + for (const recipient of request.recipients) { + try { + const result = await this.sendMail({ + accountId: request.accountId, + templateId: request.templateId, // 템플릿이 있으면 사용 + customHtml: request.customHtml, // 직접 작성한 HTML이 있으면 사용 + to: [recipient.email], + subject: request.subject, + variables: recipient.variables || {}, // 템플릿 사용 시에만 필요 + }); + + if (result.success) { + successCount++; + results.push({ + email: recipient.email, + success: true, + messageId: result.messageId, + }); + } else { + failedCount++; + results.push({ + email: recipient.email, + success: false, + error: result.error || '발송 실패', + }); + } + } catch (error: unknown) { + const err = error as Error; + failedCount++; + results.push({ + email: recipient.email, + success: false, + error: err.message, + }); + } + + // 발송 간격 (500ms) - 스팸 방지 + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + // console.log(`✅ 대량 발송 완료: 성공 ${successCount}, 실패 ${failedCount}`); + + return { + total: request.recipients.length, + success: successCount, + failed: failedCount, + results, + }; + } + /** * SMTP 연결 테스트 */ @@ -414,13 +504,13 @@ class MailSendSimpleService { // 비밀번호 복호화 const decryptedPassword = encryptionService.decrypt(account.smtpPassword); - // console.log('🔐 테스트용 비밀번호 복호화 완료'); - // console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length); + // // console.log('🔐 테스트용 비밀번호 복호화 완료'); + // // console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length); // 포트 465는 SSL/TLS를 사용해야 함 const isSecure = account.smtpPort === 465 ? true : (account.smtpSecure || false); - // console.log('🧪 SMTP 연결 테스트 시작:', { + // // console.log('🧪 SMTP 연결 테스트 시작:', { // host: account.smtpHost, // port: account.smtpPort, // secure: isSecure, @@ -443,7 +533,7 @@ class MailSendSimpleService { // 연결 테스트 await transporter.verify(); - console.log('✅ SMTP 연결 테스트 성공'); + // console.log('✅ SMTP 연결 테스트 성공'); return { success: true, message: 'SMTP 연결이 성공했습니다.' }; } catch (error) { const err = error as Error; diff --git a/backend-node/src/services/mailSentHistoryService.ts b/backend-node/src/services/mailSentHistoryService.ts index c7828888..f0a80265 100644 --- a/backend-node/src/services/mailSentHistoryService.ts +++ b/backend-node/src/services/mailSentHistoryService.ts @@ -53,7 +53,7 @@ class MailSentHistoryService { mode: 0o644, }); - console.log("발송 이력 저장:", history.id); + // console.log("발송 이력 저장:", history.id); } catch (error) { console.error("발송 이력 저장 실패:", error); // 파일 저장 실패해도 history 객체는 반환 (메일 발송은 성공했으므로) @@ -86,7 +86,7 @@ class MailSentHistoryService { try { // 디렉토리가 없으면 빈 배열 반환 if (!fs.existsSync(SENT_MAIL_DIR)) { - console.warn("메일 발송 이력 디렉토리가 없습니다:", SENT_MAIL_DIR); + // console.warn("메일 발송 이력 디렉토리가 없습니다:", SENT_MAIL_DIR); return { items: [], total: 0, @@ -124,6 +124,13 @@ class MailSentHistoryService { // 필터링 let filtered = allHistory; + // 삭제된 메일 필터 + if (query.onlyDeleted) { + filtered = filtered.filter((h) => h.deletedAt); + } else if (!query.includeDeleted) { + filtered = filtered.filter((h) => !h.deletedAt); + } + // 상태 필터 if (status !== "all") { filtered = filtered.filter((h) => h.status === status); @@ -209,9 +216,151 @@ class MailSentHistoryService { } /** - * 발송 이력 삭제 + * 임시 저장 (Draft) + */ + async saveDraft( + data: Partial & { accountId: string } + ): Promise { + // console.log("📥 백엔드에서 받은 임시 저장 데이터:", data); + + const now = new Date().toISOString(); + const draft: SentMailHistory = { + id: data.id || uuidv4(), + accountId: data.accountId, + accountName: data.accountName || "", + accountEmail: data.accountEmail || "", + to: data.to || [], + cc: data.cc, + bcc: data.bcc, + subject: data.subject || "", + htmlContent: data.htmlContent || "", + templateId: data.templateId, + templateName: data.templateName, + attachments: data.attachments, + sentAt: data.sentAt || now, + status: "draft", + isDraft: true, + updatedAt: now, + }; + + // console.log("💾 저장할 draft 객체:", draft); + + try { + if (!fs.existsSync(SENT_MAIL_DIR)) { + fs.mkdirSync(SENT_MAIL_DIR, { recursive: true, mode: 0o755 }); + } + + const filePath = path.join(SENT_MAIL_DIR, `${draft.id}.json`); + fs.writeFileSync(filePath, JSON.stringify(draft, null, 2), { + encoding: "utf-8", + mode: 0o644, + }); + + // console.log("💾 임시 저장:", draft.id); + } catch (error) { + console.error("임시 저장 실패:", error); + throw error; + } + + return draft; + } + + /** + * 임시 저장 업데이트 + */ + async updateDraft( + id: string, + data: Partial + ): Promise { + const existing = await this.getSentMailById(id); + if (!existing) { + return null; + } + + const updated: SentMailHistory = { + ...existing, + ...data, + id: existing.id, + updatedAt: new Date().toISOString(), + }; + + try { + const filePath = path.join(SENT_MAIL_DIR, `${id}.json`); + fs.writeFileSync(filePath, JSON.stringify(updated, null, 2), { + encoding: "utf-8", + mode: 0o644, + }); + + // console.log("✏️ 임시 저장 업데이트:", id); + return updated; + } catch (error) { + console.error("임시 저장 업데이트 실패:", error); + return null; + } + } + + /** + * 발송 이력 삭제 (Soft Delete) */ async deleteSentMail(id: string): Promise { + const existing = await this.getSentMailById(id); + if (!existing) { + return false; + } + + const updated: SentMailHistory = { + ...existing, + deletedAt: new Date().toISOString(), + }; + + try { + const filePath = path.join(SENT_MAIL_DIR, `${id}.json`); + fs.writeFileSync(filePath, JSON.stringify(updated, null, 2), { + encoding: "utf-8", + mode: 0o644, + }); + + // console.log("🗑️ 메일 삭제 (Soft Delete):", id); + return true; + } catch (error) { + console.error("메일 삭제 실패:", error); + return false; + } + } + + /** + * 메일 복구 + */ + async restoreMail(id: string): Promise { + const existing = await this.getSentMailById(id); + if (!existing || !existing.deletedAt) { + return false; + } + + const updated: SentMailHistory = { + ...existing, + deletedAt: undefined, + }; + + try { + const filePath = path.join(SENT_MAIL_DIR, `${id}.json`); + fs.writeFileSync(filePath, JSON.stringify(updated, null, 2), { + encoding: "utf-8", + mode: 0o644, + }); + + // console.log("♻️ 메일 복구:", id); + return true; + } catch (error) { + console.error("메일 복구 실패:", error); + return false; + } + } + + /** + * 메일 영구 삭제 (Hard Delete) + */ + async permanentlyDeleteMail(id: string): Promise { const filePath = path.join(SENT_MAIL_DIR, `${id}.json`); if (!fs.existsSync(filePath)) { @@ -220,14 +369,57 @@ class MailSentHistoryService { try { fs.unlinkSync(filePath); - console.log("🗑️ 발송 이력 삭제:", id); + // console.log("🗑️ 메일 영구 삭제:", id); return true; } catch (error) { - console.error("발송 이력 삭제 실패:", error); + console.error("메일 영구 삭제 실패:", error); return false; } } + /** + * 30일 이상 지난 삭제된 메일 자동 영구 삭제 + */ + async cleanupOldDeletedMails(): Promise { + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + let deletedCount = 0; + + try { + if (!fs.existsSync(SENT_MAIL_DIR)) { + return 0; + } + + const files = fs + .readdirSync(SENT_MAIL_DIR) + .filter((f) => f.endsWith(".json")); + + for (const file of files) { + try { + const filePath = path.join(SENT_MAIL_DIR, file); + const content = fs.readFileSync(filePath, "utf-8"); + const mail: SentMailHistory = JSON.parse(content); + + if (mail.deletedAt) { + const deletedDate = new Date(mail.deletedAt); + if (deletedDate < thirtyDaysAgo) { + fs.unlinkSync(filePath); + deletedCount++; + // console.log("🗑️ 30일 지난 메일 자동 삭제:", mail.id); + } + } + } catch (error) { + console.error(`파일 처리 실패: ${file}`, error); + } + } + } catch (error) { + console.error("자동 삭제 실패:", error); + } + + return deletedCount; + } + /** * 통계 조회 */ diff --git a/backend-node/src/services/mailTemplateFileService.ts b/backend-node/src/services/mailTemplateFileService.ts index e1a878b9..adb72fff 100644 --- a/backend-node/src/services/mailTemplateFileService.ts +++ b/backend-node/src/services/mailTemplateFileService.ts @@ -50,11 +50,25 @@ class MailTemplateFileService { process.env.NODE_ENV === "production" ? "/app/uploads/mail-templates" : path.join(process.cwd(), "uploads", "mail-templates"); - this.ensureDirectoryExists(); + // 동기적으로 디렉토리 생성 + this.ensureDirectoryExistsSync(); } /** - * 템플릿 디렉토리 생성 + * 템플릿 디렉토리 생성 (동기) + */ + private ensureDirectoryExistsSync() { + try { + const fsSync = require('fs'); + fsSync.accessSync(this.templatesDir); + } catch { + const fsSync = require('fs'); + fsSync.mkdirSync(this.templatesDir, { recursive: true, mode: 0o755 }); + } + } + + /** + * 템플릿 디렉토리 생성 (비동기) */ private async ensureDirectoryExists() { try { @@ -75,8 +89,6 @@ class MailTemplateFileService { * 모든 템플릿 목록 조회 */ async getAllTemplates(): Promise { - await this.ensureDirectoryExists(); - try { const files = await fs.readdir(this.templatesDir); const jsonFiles = files.filter((f) => f.endsWith(".json")); @@ -97,6 +109,7 @@ class MailTemplateFileService { new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() ); } catch (error) { + // 디렉토리가 없거나 읽기 실패 시 빈 배열 반환 return []; } } @@ -160,7 +173,7 @@ class MailTemplateFileService { updatedAt: new Date().toISOString(), }; - // console.log(`📝 템플릿 저장 시도: ${id}, 크기: ${JSON.stringify(updated).length} bytes`); + // // console.log(`📝 템플릿 저장 시도: ${id}, 크기: ${JSON.stringify(updated).length} bytes`); await fs.writeFile( this.getTemplatePath(id), @@ -168,7 +181,7 @@ class MailTemplateFileService { "utf-8" ); - // console.log(`✅ 템플릿 저장 성공: ${id}`); + // // console.log(`✅ 템플릿 저장 성공: ${id}`); return updated; } catch (error) { // console.error(`❌ 템플릿 저장 실패: ${id}`, error); diff --git a/backend-node/src/services/riskAlertCacheService.ts b/backend-node/src/services/riskAlertCacheService.ts index cc4de181..ce8b6089 100644 --- a/backend-node/src/services/riskAlertCacheService.ts +++ b/backend-node/src/services/riskAlertCacheService.ts @@ -34,16 +34,35 @@ export class RiskAlertCacheService { */ public startAutoRefresh(): void { console.log('🔄 리스크/알림 자동 갱신 시작 (10분 간격)'); + console.log(' - 기상특보: 즉시 호출'); + console.log(' - 교통사고/도로공사: 10분 후 첫 호출'); - // 즉시 첫 갱신 - this.refreshCache(); + // 기상특보만 즉시 호출 (ITS API는 10분 후부터) + this.refreshWeatherOnly(); - // 10분마다 갱신 (600,000ms) + // 10분마다 전체 갱신 (600,000ms) this.updateInterval = setInterval(() => { this.refreshCache(); }, 10 * 60 * 1000); } + /** + * 기상특보만 갱신 (재시작 시 사용) + */ + private async refreshWeatherOnly(): Promise { + try { + console.log('🌤️ 기상특보만 즉시 갱신 중...'); + const weatherAlerts = await this.riskAlertService.getWeatherAlerts(); + + this.cachedAlerts = weatherAlerts; + this.lastUpdated = new Date(); + + console.log(`✅ 기상특보 갱신 완료! (${weatherAlerts.length}건)`); + } catch (error: any) { + console.error('❌ 기상특보 갱신 실패:', error.message); + } + } + /** * 자동 갱신 중지 */ diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index a984fa85..198c850b 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -71,8 +71,9 @@ export class ScreenManagementService { // 화면 생성 (Raw Query) const [screen] = await query( `INSERT INTO screen_definitions ( - screen_name, screen_code, table_name, company_code, description, created_by - ) VALUES ($1, $2, $3, $4, $5, $6) + screen_name, screen_code, table_name, company_code, description, created_by, + db_source_type, db_connection_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, [ screenData.screenName, @@ -81,6 +82,8 @@ export class ScreenManagementService { screenData.companyCode, screenData.description || null, screenData.createdBy, + screenData.dbSourceType || "internal", + screenData.dbConnectionId || null, ] ); @@ -1779,6 +1782,8 @@ export class ScreenManagementService { createdBy: data.created_by, updatedDate: data.updated_date, updatedBy: data.updated_by, + dbSourceType: data.db_source_type || "internal", + dbConnectionId: data.db_connection_id || undefined, }; } diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 83f3a696..10de1e73 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -3118,4 +3118,410 @@ export class TableManagementService { // 기본값 return "text"; } + + // ======================================== + // 🎯 테이블 로그 시스템 + // ======================================== + + /** + * 로그 테이블 생성 + */ + async createLogTable( + tableName: string, + pkColumn: { columnName: string; dataType: string }, + userId?: string + ): Promise { + try { + const logTableName = `${tableName}_log`; + const triggerFuncName = `${tableName}_log_trigger_func`; + const triggerName = `${tableName}_audit_trigger`; + + logger.info(`로그 테이블 생성 시작: ${logTableName}`); + + // 로그 테이블 DDL 생성 + const logTableDDL = this.generateLogTableDDL( + logTableName, + tableName, + pkColumn.columnName, + pkColumn.dataType + ); + + // 트리거 함수 DDL 생성 + const triggerFuncDDL = this.generateTriggerFunctionDDL( + triggerFuncName, + logTableName, + tableName, + pkColumn.columnName + ); + + // 트리거 DDL 생성 + const triggerDDL = this.generateTriggerDDL( + triggerName, + tableName, + triggerFuncName + ); + + // 트랜잭션으로 실행 + await transaction(async (client) => { + // 1. 로그 테이블 생성 + await client.query(logTableDDL); + logger.info(`로그 테이블 생성 완료: ${logTableName}`); + + // 2. 트리거 함수 생성 + await client.query(triggerFuncDDL); + logger.info(`트리거 함수 생성 완료: ${triggerFuncName}`); + + // 3. 트리거 생성 + await client.query(triggerDDL); + logger.info(`트리거 생성 완료: ${triggerName}`); + + // 4. 로그 설정 저장 + await client.query( + `INSERT INTO table_log_config ( + original_table_name, log_table_name, trigger_name, + trigger_function_name, created_by + ) VALUES ($1, $2, $3, $4, $5)`, + [tableName, logTableName, triggerName, triggerFuncName, userId] + ); + logger.info(`로그 설정 저장 완료: ${tableName}`); + }); + + logger.info(`로그 테이블 생성 완료: ${logTableName}`); + } catch (error) { + logger.error(`로그 테이블 생성 실패: ${tableName}`, error); + throw new Error( + `로그 테이블 생성 실패: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * 로그 테이블 DDL 생성 + */ + private generateLogTableDDL( + logTableName: string, + originalTableName: string, + pkColumnName: string, + pkDataType: string + ): string { + return ` + CREATE TABLE ${logTableName} ( + log_id SERIAL PRIMARY KEY, + operation_type VARCHAR(10) NOT NULL, + original_id VARCHAR(100), + changed_column VARCHAR(100), + old_value TEXT, + new_value TEXT, + changed_by VARCHAR(50), + changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + ip_address VARCHAR(50), + user_agent TEXT, + full_row_before JSONB, + full_row_after JSONB + ); + + CREATE INDEX idx_${logTableName}_original_id ON ${logTableName}(original_id); + CREATE INDEX idx_${logTableName}_changed_at ON ${logTableName}(changed_at); + CREATE INDEX idx_${logTableName}_operation ON ${logTableName}(operation_type); + + COMMENT ON TABLE ${logTableName} IS '${originalTableName} 테이블 변경 이력'; + COMMENT ON COLUMN ${logTableName}.operation_type IS '작업 유형 (INSERT/UPDATE/DELETE)'; + COMMENT ON COLUMN ${logTableName}.original_id IS '원본 테이블 PK 값'; + COMMENT ON COLUMN ${logTableName}.changed_column IS '변경된 컬럼명'; + COMMENT ON COLUMN ${logTableName}.old_value IS '변경 전 값'; + COMMENT ON COLUMN ${logTableName}.new_value IS '변경 후 값'; + COMMENT ON COLUMN ${logTableName}.changed_by IS '변경자 ID'; + COMMENT ON COLUMN ${logTableName}.changed_at IS '변경 시각'; + COMMENT ON COLUMN ${logTableName}.ip_address IS '변경 요청 IP'; + COMMENT ON COLUMN ${logTableName}.full_row_before IS '변경 전 전체 행 (JSON)'; + COMMENT ON COLUMN ${logTableName}.full_row_after IS '변경 후 전체 행 (JSON)'; + `; + } + + /** + * 트리거 함수 DDL 생성 + */ + private generateTriggerFunctionDDL( + funcName: string, + logTableName: string, + originalTableName: string, + pkColumnName: string + ): string { + return ` + CREATE OR REPLACE FUNCTION ${funcName}() + RETURNS TRIGGER AS $$ + DECLARE + v_column_name TEXT; + v_old_value TEXT; + v_new_value TEXT; + v_user_id VARCHAR(50); + v_ip_address VARCHAR(50); + BEGIN + v_user_id := current_setting('app.user_id', TRUE); + v_ip_address := current_setting('app.ip_address', TRUE); + + IF (TG_OP = 'INSERT') THEN + EXECUTE format( + 'INSERT INTO ${logTableName} (operation_type, original_id, changed_by, ip_address, full_row_after) + VALUES ($1, ($2).%I, $3, $4, $5)', + '${pkColumnName}' + ) + USING 'INSERT', NEW, v_user_id, v_ip_address, row_to_json(NEW)::jsonb; + RETURN NEW; + + ELSIF (TG_OP = 'UPDATE') THEN + FOR v_column_name IN + SELECT column_name + FROM information_schema.columns + WHERE table_name = '${originalTableName}' + AND table_schema = 'public' + LOOP + EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name) + INTO v_old_value, v_new_value + USING OLD, NEW; + + IF v_old_value IS DISTINCT FROM v_new_value THEN + EXECUTE format( + 'INSERT INTO ${logTableName} (operation_type, original_id, changed_column, old_value, new_value, changed_by, ip_address, full_row_before, full_row_after) + VALUES ($1, ($2).%I, $3, $4, $5, $6, $7, $8, $9)', + '${pkColumnName}' + ) + USING 'UPDATE', NEW, v_column_name, v_old_value, v_new_value, v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb; + END IF; + END LOOP; + RETURN NEW; + + ELSIF (TG_OP = 'DELETE') THEN + EXECUTE format( + 'INSERT INTO ${logTableName} (operation_type, original_id, changed_by, ip_address, full_row_before) + VALUES ($1, ($2).%I, $3, $4, $5)', + '${pkColumnName}' + ) + USING 'DELETE', OLD, v_user_id, v_ip_address, row_to_json(OLD)::jsonb; + RETURN OLD; + END IF; + + RETURN NULL; + END; + $$ LANGUAGE plpgsql; + `; + } + + /** + * 트리거 DDL 생성 + */ + private generateTriggerDDL( + triggerName: string, + tableName: string, + funcName: string + ): string { + return ` + CREATE TRIGGER ${triggerName} + AFTER INSERT OR UPDATE OR DELETE ON ${tableName} + FOR EACH ROW EXECUTE FUNCTION ${funcName}(); + `; + } + + /** + * 로그 설정 조회 + */ + async getLogConfig(tableName: string): Promise<{ + originalTableName: string; + logTableName: string; + triggerName: string; + triggerFunctionName: string; + isActive: string; + createdAt: Date; + createdBy: string; + } | null> { + try { + logger.info(`로그 설정 조회: ${tableName}`); + + const result = await queryOne<{ + original_table_name: string; + log_table_name: string; + trigger_name: string; + trigger_function_name: string; + is_active: string; + created_at: Date; + created_by: string; + }>( + `SELECT + original_table_name, log_table_name, trigger_name, + trigger_function_name, is_active, created_at, created_by + FROM table_log_config + WHERE original_table_name = $1`, + [tableName] + ); + + if (!result) { + return null; + } + + return { + originalTableName: result.original_table_name, + logTableName: result.log_table_name, + triggerName: result.trigger_name, + triggerFunctionName: result.trigger_function_name, + isActive: result.is_active, + createdAt: result.created_at, + createdBy: result.created_by, + }; + } catch (error) { + logger.error(`로그 설정 조회 실패: ${tableName}`, error); + throw error; + } + } + + /** + * 로그 데이터 조회 + */ + async getLogData( + tableName: string, + options: { + page: number; + size: number; + operationType?: string; + startDate?: string; + endDate?: string; + changedBy?: string; + originalId?: string; + } + ): Promise<{ + data: any[]; + total: number; + page: number; + size: number; + totalPages: number; + }> { + try { + const logTableName = `${tableName}_log`; + const offset = (options.page - 1) * options.size; + + logger.info(`로그 데이터 조회: ${logTableName}`, options); + + // WHERE 조건 구성 + const whereConditions: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (options.operationType) { + whereConditions.push(`operation_type = $${paramIndex}`); + values.push(options.operationType); + paramIndex++; + } + + if (options.startDate) { + whereConditions.push(`changed_at >= $${paramIndex}::timestamp`); + values.push(options.startDate); + paramIndex++; + } + + if (options.endDate) { + whereConditions.push(`changed_at <= $${paramIndex}::timestamp`); + values.push(options.endDate); + paramIndex++; + } + + if (options.changedBy) { + whereConditions.push(`changed_by = $${paramIndex}`); + values.push(options.changedBy); + paramIndex++; + } + + if (options.originalId) { + whereConditions.push(`original_id::text = $${paramIndex}`); + values.push(options.originalId); + paramIndex++; + } + + const whereClause = + whereConditions.length > 0 + ? `WHERE ${whereConditions.join(" AND ")}` + : ""; + + // 전체 개수 조회 + const countQuery = `SELECT COUNT(*) as count FROM ${logTableName} ${whereClause}`; + const countResult = await query(countQuery, values); + const total = parseInt(countResult[0].count); + + // 데이터 조회 + const dataQuery = ` + SELECT * FROM ${logTableName} + ${whereClause} + ORDER BY changed_at DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1} + `; + + const data = await query(dataQuery, [ + ...values, + options.size, + offset, + ]); + + const totalPages = Math.ceil(total / options.size); + + logger.info( + `로그 데이터 조회 완료: ${logTableName}, 총 ${total}건, ${data.length}개 반환` + ); + + return { + data, + total, + page: options.page, + size: options.size, + totalPages, + }; + } catch (error) { + logger.error(`로그 데이터 조회 실패: ${tableName}`, error); + throw error; + } + } + + /** + * 로그 테이블 활성화/비활성화 + */ + async toggleLogTable(tableName: string, isActive: boolean): Promise { + try { + const logConfig = await this.getLogConfig(tableName); + if (!logConfig) { + throw new Error(`로그 설정을 찾을 수 없습니다: ${tableName}`); + } + + logger.info( + `로그 테이블 ${isActive ? "활성화" : "비활성화"}: ${tableName}` + ); + + await transaction(async (client) => { + // 트리거 활성화/비활성화 + if (isActive) { + await client.query( + `ALTER TABLE ${tableName} ENABLE TRIGGER ${logConfig.triggerName}` + ); + } else { + await client.query( + `ALTER TABLE ${tableName} DISABLE TRIGGER ${logConfig.triggerName}` + ); + } + + // 설정 업데이트 + await client.query( + `UPDATE table_log_config + SET is_active = $1, updated_at = NOW() + WHERE original_table_name = $2`, + [isActive ? "Y" : "N", tableName] + ); + }); + + logger.info( + `로그 테이블 ${isActive ? "활성화" : "비활성화"} 완료: ${tableName}` + ); + } catch (error) { + logger.error( + `로그 테이블 ${isActive ? "활성화" : "비활성화"} 실패: ${tableName}`, + error + ); + throw error; + } + } } diff --git a/backend-node/src/services/todoService.ts b/backend-node/src/services/todoService.ts index 33becbb9..c7f12dee 100644 --- a/backend-node/src/services/todoService.ts +++ b/backend-node/src/services/todoService.ts @@ -155,10 +155,15 @@ export class TodoService { updates: Partial ): Promise { try { - if (DATA_SOURCE === "database") { + // 먼저 데이터베이스에서 찾아보고, 없으면 파일에서 찾기 + try { return await this.updateTodoDB(id, updates); - } else { - return this.updateTodoFile(id, updates); + } catch (dbError: any) { + // 데이터베이스에서 찾지 못했으면 파일에서 찾기 + if (dbError.message && dbError.message.includes("찾을 수 없습니다")) { + return this.updateTodoFile(id, updates); + } + throw dbError; } } catch (error) { logger.error("❌ To-Do 수정 오류:", error); @@ -171,10 +176,16 @@ export class TodoService { */ public async deleteTodo(id: string): Promise { try { - if (DATA_SOURCE === "database") { + // 먼저 데이터베이스에서 찾아보고, 없으면 파일에서 찾기 + try { await this.deleteTodoDB(id); - } else { - this.deleteTodoFile(id); + } catch (dbError: any) { + // 데이터베이스에서 찾지 못했으면 파일에서 찾기 + if (dbError.message && dbError.message.includes("찾을 수 없습니다")) { + this.deleteTodoFile(id); + } else { + throw dbError; + } } logger.info(`✅ To-Do 삭제: ${id}`); } catch (error) { diff --git a/backend-node/src/types/dashboard.ts b/backend-node/src/types/dashboard.ts index b03acbff..7d6267a7 100644 --- a/backend-node/src/types/dashboard.ts +++ b/backend-node/src/types/dashboard.ts @@ -45,6 +45,17 @@ export interface DashboardElement { layoutId: number; layoutName?: string; }; + customMetricConfig?: { + metrics: Array<{ + id: string; + field: string; + label: string; + aggregation: "count" | "sum" | "avg" | "min" | "max"; + unit: string; + color: "indigo" | "green" | "blue" | "purple" | "orange" | "gray"; + decimals: number; + }>; + }; } export interface Dashboard { diff --git a/backend-node/src/types/externalRestApiTypes.ts b/backend-node/src/types/externalRestApiTypes.ts new file mode 100644 index 00000000..061ab6b8 --- /dev/null +++ b/backend-node/src/types/externalRestApiTypes.ts @@ -0,0 +1,78 @@ +// 외부 REST API 연결 관리 타입 정의 + +export type AuthType = "none" | "api-key" | "bearer" | "basic" | "oauth2"; + +export interface ExternalRestApiConnection { + id?: number; + connection_name: string; + description?: string; + base_url: string; + default_headers: Record; + auth_type: AuthType; + auth_config?: { + // API Key + keyLocation?: "header" | "query"; + keyName?: string; + keyValue?: string; + + // Bearer Token + token?: string; + + // Basic Auth + username?: string; + password?: string; + + // OAuth2 + clientId?: string; + clientSecret?: string; + tokenUrl?: string; + accessToken?: string; + }; + timeout?: number; + retry_count?: number; + retry_delay?: number; + company_code: string; + is_active: string; + created_date?: Date; + created_by?: string; + updated_date?: Date; + updated_by?: string; + last_test_date?: Date; + last_test_result?: string; + last_test_message?: string; +} + +export interface ExternalRestApiConnectionFilter { + auth_type?: string; + is_active?: string; + company_code?: string; + search?: string; +} + +export interface RestApiTestRequest { + id?: number; + base_url: string; + endpoint?: string; + method?: "GET" | "POST" | "PUT" | "DELETE"; + headers?: Record; + auth_type?: AuthType; + auth_config?: any; + timeout?: number; +} + +export interface RestApiTestResult { + success: boolean; + message: string; + response_time?: number; + status_code?: number; + response_data?: any; + error_details?: string; +} + +export const AUTH_TYPE_OPTIONS = [ + { value: "none", label: "인증 없음" }, + { value: "api-key", label: "API Key" }, + { value: "bearer", label: "Bearer Token" }, + { value: "basic", label: "Basic Auth" }, + { value: "oauth2", label: "OAuth 2.0" }, +]; diff --git a/backend-node/src/types/flow.ts b/backend-node/src/types/flow.ts index 3483b617..4368ae1a 100644 --- a/backend-node/src/types/flow.ts +++ b/backend-node/src/types/flow.ts @@ -8,6 +8,8 @@ export interface FlowDefinition { name: string; description?: string; tableName: string; + dbSourceType?: "internal" | "external"; // 데이터베이스 소스 타입 + dbConnectionId?: number; // 외부 DB 연결 ID (external인 경우) isActive: boolean; createdBy?: string; createdAt: Date; @@ -19,6 +21,8 @@ export interface CreateFlowDefinitionRequest { name: string; description?: string; tableName: string; + dbSourceType?: "internal" | "external"; // 데이터베이스 소스 타입 + dbConnectionId?: number; // 외부 DB 연결 ID } // 플로우 정의 수정 요청 @@ -178,6 +182,9 @@ export interface FlowAuditLog { targetDataId?: string; statusFrom?: string; statusTo?: string; + // 외부 DB 연결 정보 + dbConnectionId?: number; + dbConnectionName?: string; // 조인 필드 fromStepName?: string; toStepName?: string; diff --git a/backend-node/src/types/mailSentHistory.ts b/backend-node/src/types/mailSentHistory.ts index 1366acf4..856cbd4f 100644 --- a/backend-node/src/types/mailSentHistory.ts +++ b/backend-node/src/types/mailSentHistory.ts @@ -24,13 +24,18 @@ export interface SentMailHistory { // 발송 정보 sentAt: string; // 발송 시간 (ISO 8601) - status: 'success' | 'failed'; // 발송 상태 + status: 'success' | 'failed' | 'draft'; // 발송 상태 (draft 추가) messageId?: string; // SMTP 메시지 ID (성공 시) errorMessage?: string; // 오류 메시지 (실패 시) // 발송 결과 accepted?: string[]; // 수락된 이메일 주소 rejected?: string[]; // 거부된 이메일 주소 + + // 임시 저장 및 삭제 + isDraft?: boolean; // 임시 저장 여부 + deletedAt?: string; // 삭제 시간 (ISO 8601) + updatedAt?: string; // 수정 시간 (ISO 8601) } export interface AttachmentInfo { @@ -45,12 +50,14 @@ export interface SentMailListQuery { page?: number; // 페이지 번호 (1부터 시작) limit?: number; // 페이지당 항목 수 searchTerm?: string; // 검색어 (제목, 받는사람) - status?: 'success' | 'failed' | 'all'; // 필터: 상태 + status?: 'success' | 'failed' | 'draft' | 'all'; // 필터: 상태 (draft 추가) accountId?: string; // 필터: 발송 계정 startDate?: string; // 필터: 시작 날짜 (ISO 8601) endDate?: string; // 필터: 종료 날짜 (ISO 8601) - sortBy?: 'sentAt' | 'subject'; // 정렬 기준 + sortBy?: 'sentAt' | 'subject' | 'updatedAt'; // 정렬 기준 (updatedAt 추가) sortOrder?: 'asc' | 'desc'; // 정렬 순서 + includeDeleted?: boolean; // 삭제된 메일 포함 여부 + onlyDeleted?: boolean; // 삭제된 메일만 조회 } export interface SentMailListResponse { diff --git a/backend-node/src/types/screen.ts b/backend-node/src/types/screen.ts index 8075c78c..304c589c 100644 --- a/backend-node/src/types/screen.ts +++ b/backend-node/src/types/screen.ts @@ -151,6 +151,8 @@ export interface ScreenDefinition { createdBy?: string; updatedDate: Date; updatedBy?: string; + dbSourceType?: "internal" | "external"; + dbConnectionId?: number; } // 화면 생성 요청 @@ -161,6 +163,8 @@ export interface CreateScreenRequest { companyCode: string; description?: string; createdBy?: string; + dbSourceType?: "internal" | "external"; + dbConnectionId?: number; } // 화면 수정 요청 diff --git a/docker/prod/backend.Dockerfile b/docker/prod/backend.Dockerfile index f5d54a9e..cba88e5c 100644 --- a/docker/prod/backend.Dockerfile +++ b/docker/prod/backend.Dockerfile @@ -1,13 +1,9 @@ -# syntax=docker/dockerfile:1 - -# Base image (Debian-based for glibc + OpenSSL compatibility) -FROM node:20-bookworm-slim AS base +# Base image (WACE Docker Hub) +FROM dockerhub.wace.me/node:20.19-alpine.linux AS base WORKDIR /app ENV NODE_ENV=production # Install OpenSSL, curl (for healthcheck), and required certs -RUN apt-get update \ - && apt-get install -y --no-install-recommends openssl ca-certificates curl \ - && rm -rf /var/lib/apt/lists/* +RUN apk add --no-cache openssl ca-certificates curl # Dependencies stage (install production dependencies) FROM base AS deps @@ -15,7 +11,7 @@ COPY package*.json ./ RUN npm ci --omit=dev --prefer-offline --no-audit && npm cache clean --force # Build stage (compile TypeScript) -FROM node:20-bookworm-slim AS build +FROM dockerhub.wace.me/node:20.19-alpine.linux AS build WORKDIR /app COPY package*.json ./ RUN npm ci --prefer-offline --no-audit && npm cache clean --force @@ -27,8 +23,8 @@ RUN npm run build FROM base AS runner ENV NODE_ENV=production -# Create non-root user -RUN groupadd -r appgroup && useradd -r -g appgroup appuser +# Create non-root user (Alpine 방식) +RUN addgroup -S appgroup && adduser -S -G appgroup appuser # Copy production node_modules COPY --from=deps /app/node_modules ./node_modules diff --git a/docker/prod/docker-compose.backend.prod.yml b/docker/prod/docker-compose.backend.prod.yml index 507a555b..e8a4e04c 100644 --- a/docker/prod/docker-compose.backend.prod.yml +++ b/docker/prod/docker-compose.backend.prod.yml @@ -5,25 +5,18 @@ services: context: ../../backend-node dockerfile: ../docker/prod/backend.Dockerfile container_name: plm-backend - restart: always environment: - NODE_ENV: production - PORT: "3001" - DATABASE_URL: postgresql://postgres:ph0909!!@39.117.244.52:11132/plm - JWT_SECRET: ilshin-plm-super-secret-jwt-key-2024 - JWT_EXPIRES_IN: 24h - CORS_ORIGIN: https://v1.vexplor.com - CORS_CREDENTIALS: "true" - LOG_LEVEL: info - volumes: - - /home/vexplor/backend_data:/app/uploads - labels: - - traefik.enable=true - - traefik.http.routers.backend.rule=Host(`api.vexplor.com`) - - traefik.http.routers.backend.entrypoints=websecure,web - - traefik.http.routers.backend.tls=true - - traefik.http.routers.backend.tls.certresolver=le - - traefik.http.services.backend.loadbalancer.server.port=3001 + - NODE_ENV=production + - PORT=8080 + - HOST=0.0.0.0 # 모든 인터페이스에서 바인딩 + - DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm + - JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024 + - JWT_EXPIRES_IN=24h + - CORS_ORIGIN=http://192.168.0.70:5555,http://39.117.244.52:5555,http://localhost:9771 + - CORS_CREDENTIALS=true + - LOG_LEVEL=info + - ENCRYPTION_KEY=ilshin-plm-mail-encryption-key-32characters-2024-secure + restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3001/health"] interval: 30s diff --git a/docker/prod/frontend.Dockerfile b/docker/prod/frontend.Dockerfile index b2e1600d..e4741ad5 100644 --- a/docker/prod/frontend.Dockerfile +++ b/docker/prod/frontend.Dockerfile @@ -1,5 +1,5 @@ # Multi-stage build for Next.js -FROM node:18-alpine AS base +FROM dockerhub.wace.me/node:20.19-alpine.linux AS base # curl 설치 (헬스체크용) RUN apk add --no-cache curl diff --git a/docs/ADMIN_STYLE_GUIDE_EXAMPLE.md b/docs/ADMIN_STYLE_GUIDE_EXAMPLE.md new file mode 100644 index 00000000..2ff7f4e1 --- /dev/null +++ b/docs/ADMIN_STYLE_GUIDE_EXAMPLE.md @@ -0,0 +1,435 @@ +# 관리자 페이지 스타일 가이드 적용 예시 + +## 개요 + +사용자 관리 페이지를 예시로 shadcn/ui 스타일 가이드에 맞춰 재작성했습니다. +이 예시를 기준으로 다른 관리자 페이지들도 일관된 스타일로 통일할 수 있습니다. + +## 적용된 주요 원칙 + +### 1. Color System (색상 시스템) + +**CSS Variables 사용 (하드코딩된 색상 금지)** +```tsx +// ❌ 잘못된 예시 +
+ +// ✅ 올바른 예시 +
+
+
+
+
+``` + +**적용 사례:** +- 페이지 배경: `bg-background` +- 카드 배경: `bg-card` +- 보조 텍스트: `text-muted-foreground` +- 주요 액션: `text-primary`, `border-primary` +- 에러 메시지: `text-destructive`, `bg-destructive/10` + +### 2. Typography (타이포그래피) + +**일관된 폰트 크기와 가중치** +```tsx +// 페이지 제목 +

사용자 관리

+ +// 섹션 제목 +

고급 검색 옵션

+ +// 본문 텍스트 +

설명 텍스트

+ +// 라벨 + + +// 보조 텍스트 +

도움말

+``` + +### 3. Spacing System (간격) + +**일관된 간격 사용 (4px 기준)** +```tsx +// 컴포넌트 간 간격 +
// 24px (페이지 레벨) +
// 16px (섹션 레벨) +
// 8px (필드 레벨) + +// 패딩 +
// 24px (카드) +
// 16px (내부 섹션) + +// 갭 +
// 16px (flex/grid) +
// 8px (버튼 그룹) +``` + +### 4. Border & Radius (테두리 및 둥근 모서리) + +**표준 radius 사용** +```tsx +// 카드/패널 +
+ +// 입력 필드 + + +// 버튼 + + +// Secondary 액션 + + +// Ghost 버튼 (아이콘 전용) + +``` + +**크기 표준:** +- `h-10`: 기본 버튼 (40px) +- `h-9`: 작은 버튼 (36px) +- `h-8`: 아이콘 버튼 (32px) + +### 6. Input States (입력 필드 상태) + +**표준 Input 스타일** +```tsx +// 기본 + + +// 포커스 (자동 적용) +// focus:ring-2 focus:ring-ring + +// 로딩/액티브 + + +// 비활성화 + +``` + +### 7. Form Structure (폼 구조) + +**표준 필드 구조** +```tsx +
+ + +

+ 도움말 텍스트 +

+
+``` + +### 8. Table Structure (테이블 구조) + +**표준 테이블 스타일** +```tsx +
+ + + + + 컬럼명 + + + + + + + 데이터 + + + +
+
+``` + +**높이 표준:** +- 헤더: `h-12` (48px) +- 데이터 행: `h-16` (64px) + +### 9. Loading States (로딩 상태) + +**Skeleton UI** +```tsx +
+``` + +### 10. Empty States (빈 상태) + +**표준 Empty State** +```tsx + +
+

등록된 데이터가 없습니다.

+
+
+``` + +### 11. Error States (에러 상태) + +**표준 에러 메시지** +```tsx +
+
+

오류가 발생했습니다

+ +
+

{errorMessage}

+
+``` + +### 12. Responsive Design (반응형) + +**모바일 우선 접근** +```tsx +// 레이아웃 +
+ +// 그리드 +
+ +// 텍스트 +

+ +// 간격 +
+``` + +### 13. Accessibility (접근성) + +**필수 적용 사항** +```tsx +// Label과 Input 연결 + + + +// 버튼에 aria-label + + +// Switch에 aria-label + +``` + +## 페이지 구조 템플릿 + +### Page Component +```tsx +export default function AdminPage() { + return ( +
+
+ {/* 페이지 헤더 */} +
+

페이지 제목

+

페이지 설명

+
+ + {/* 메인 컨텐츠 */} + +
+
+ ); +} +``` + +### Toolbar Component +```tsx +export function Toolbar() { + return ( +
+ {/* 검색 영역 */} +
+
+ {/* 검색 입력 */} +
+
+ + +
+
+ + {/* 버튼 */} + +
+
+ + {/* 액션 버튼 영역 */} +
+
+ 총 {count.toLocaleString()} 건 +
+ + +
+
+ ); +} +``` + +## 적용해야 할 다른 관리자 페이지 + +### 우선순위 1 (핵심 페이지) +- [ ] 메뉴 관리 (`/admin/menu`) +- [ ] 공통코드 관리 (`/admin/commonCode`) +- [ ] 회사 관리 (`/admin/company`) +- [ ] 테이블 관리 (`/admin/tableMng`) + +### 우선순위 2 (자주 사용하는 페이지) +- [ ] 외부 연결 관리 (`/admin/external-connections`) +- [ ] 외부 호출 설정 (`/admin/external-call-configs`) +- [ ] 배치 관리 (`/admin/batch-management`) +- [ ] 레이아웃 관리 (`/admin/layouts`) + +### 우선순위 3 (기타 관리 페이지) +- [ ] 템플릿 관리 (`/admin/templates`) +- [ ] 표준 관리 (`/admin/standards`) +- [ ] 다국어 관리 (`/admin/i18n`) +- [ ] 수집 관리 (`/admin/collection-management`) + +## 체크리스트 + +각 페이지 작업 시 다음을 확인하세요: + +### 레이아웃 +- [ ] `bg-background` 사용 (하드코딩된 색상 없음) +- [ ] `container mx-auto space-y-6 p-6` 구조 +- [ ] 페이지 헤더에 `border-b pb-4` + +### 색상 +- [ ] CSS Variables만 사용 (`bg-card`, `text-muted-foreground` 등) +- [ ] `bg-gray-*`, `text-gray-*` 등 하드코딩 제거 + +### 타이포그래피 +- [ ] 페이지 제목: `text-3xl font-bold tracking-tight` +- [ ] 섹션 제목: `text-sm font-semibold` +- [ ] 본문: `text-sm` +- [ ] 보조 텍스트: `text-xs text-muted-foreground` + +### 간격 +- [ ] 페이지 레벨: `space-y-6` +- [ ] 섹션 레벨: `space-y-4` +- [ ] 필드 레벨: `space-y-2` +- [ ] 카드 패딩: `p-4` 또는 `p-6` + +### 버튼 +- [ ] 표준 variants 사용 (`default`, `outline`, `ghost`) +- [ ] 표준 크기: `h-10` (기본), `h-9` (작음), `h-8` (아이콘) +- [ ] 텍스트: `text-sm font-medium` +- [ ] 아이콘 + 텍스트: `gap-2` + +### 입력 필드 +- [ ] 높이: `h-10` +- [ ] 텍스트: `text-sm` +- [ ] Label과 Input `htmlFor`/`id` 연결 +- [ ] `space-y-2` 구조 + +### 테이블 +- [ ] `rounded-lg border bg-card shadow-sm` +- [ ] 헤더: `h-12 text-sm font-semibold bg-muted/50` +- [ ] 데이터 행: `h-16 text-sm` +- [ ] Hover: `hover:bg-muted/50` + +### 반응형 +- [ ] 모바일 우선 디자인 +- [ ] `sm:`, `md:`, `lg:` 브레이크포인트 사용 +- [ ] `flex-col sm:flex-row` 패턴 + +### 접근성 +- [ ] Label `htmlFor` 속성 +- [ ] Input `id` 속성 +- [ ] 버튼 `aria-label` +- [ ] Switch `aria-label` + +## 마이그레이션 절차 + +1. **페이지 컴포넌트 수정** (`page.tsx`) + - 레이아웃 구조 변경 + - 색상 CSS Variables로 변경 + - 페이지 헤더 표준화 + +2. **Toolbar 컴포넌트 수정** + - 검색 영역 스타일 통일 + - 버튼 스타일 표준화 + - 반응형 레이아웃 적용 + +3. **Table 컴포넌트 수정** + - 테이블 컨테이너 스타일 통일 + - 헤더/데이터 행 높이 표준화 + - 로딩/Empty State 표준화 + +4. **Form 컴포넌트 수정** (있는 경우) + - 필드 구조 표준화 + - 라벨과 입력 필드 연결 + - 에러 메시지 스타일 통일 + +5. **Modal 컴포넌트 수정** (있는 경우) + - Dialog 표준 패턴 적용 + - 반응형 크기 (`max-w-[95vw] sm:max-w-[500px]`) + - 버튼 스타일 표준화 + +6. **린트 에러 확인** + ```bash + # 수정한 파일들 확인 + npm run lint + ``` + +7. **테스트** + - 기능 동작 확인 + - 반응형 확인 (모바일/태블릿/데스크톱) + - 다크모드 확인 (있는 경우) + +## 참고 파일 + +### 완성된 예시 +- `frontend/app/(main)/admin/userMng/page.tsx` +- `frontend/components/admin/UserToolbar.tsx` +- `frontend/components/admin/UserTable.tsx` +- `frontend/components/admin/UserManagement.tsx` + +### 스타일 가이드 +- `.cursorrules` - 전체 스타일 규칙 +- Section 1-21: 각 스타일 요소별 상세 가이드 + diff --git a/frontend/app/(main)/admin/batchmng/page.tsx b/frontend/app/(main)/admin/batchmng/page.tsx index 184ae578..46aedf1f 100644 --- a/frontend/app/(main)/admin/batchmng/page.tsx +++ b/frontend/app/(main)/admin/batchmng/page.tsx @@ -1,17 +1,8 @@ "use client"; import React, { useState, useEffect } from "react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow -} from "@/components/ui/table"; import { Plus, Search, @@ -26,6 +17,7 @@ import { BatchMapping, } from "@/lib/api/batch"; import BatchCard from "@/components/admin/BatchCard"; +import { ScrollToTop } from "@/components/common/ScrollToTop"; export default function BatchManagementPage() { const router = useRouter(); @@ -178,187 +170,198 @@ export default function BatchManagementPage() { }; return ( -
- {/* 헤더 */} -
-
-

배치 관리

-

데이터베이스 간 배치 작업을 관리합니다.

+
+
+ {/* 페이지 헤더 */} +
+

배치 관리

+

데이터베이스 간 배치 작업을 관리합니다.

- -
- {/* 검색 및 필터 */} - - -
-
- - handleSearch(e.target.value)} - className="pl-10" - /> + {/* 검색 및 액션 영역 */} +
+ {/* 검색 영역 */} +
+
+
+ + handleSearch(e.target.value)} + className="h-10 pl-10 text-sm" + /> +
+
- - - {/* 배치 목록 */} - - - - 배치 목록 ({batchConfigs.length}개) - {loading && } - - - - {batchConfigs.length === 0 ? ( -
- -

배치가 없습니다

-

- {searchTerm ? "검색 결과가 없습니다." : "새로운 배치를 추가해보세요."} -

+ {/* 액션 버튼 영역 */} +
+
+ 총{" "} + + {batchConfigs.length.toLocaleString()} + {" "} + 건 +
+ +
+
+ + {/* 배치 목록 */} + {batchConfigs.length === 0 ? ( +
+
+ +
+

배치가 없습니다

+

+ {searchTerm ? "검색 결과가 없습니다." : "새로운 배치를 추가해보세요."} +

+
{!searchTerm && ( )}
- ) : ( -
- {batchConfigs.map((batch) => ( - { - console.log("🖱️ 비활성화/활성화 버튼 클릭:", { batchId, currentStatus }); - toggleBatchStatus(batchId, currentStatus); - }} - onEdit={(batchId) => router.push(`/admin/batchmng/edit/${batchId}`)} - onDelete={deleteBatch} - getMappingSummary={getMappingSummary} - /> - ))} -
- )} - - - - {/* 페이지네이션 */} - {totalPages > 1 && ( -
- - -
- {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { - const pageNum = i + 1; - return ( - - ); - })}
- - -
- )} + ) : ( +
+ {batchConfigs.map((batch) => ( + { + toggleBatchStatus(batchId, currentStatus); + }} + onEdit={(batchId) => router.push(`/admin/batchmng/edit/${batchId}`)} + onDelete={deleteBatch} + getMappingSummary={getMappingSummary} + /> + ))} +
+ )} - {/* 배치 타입 선택 모달 */} - {isBatchTypeModalOpen && ( -
- - - 배치 타입 선택 - - -
- {/* DB → DB */} -
handleBatchTypeSelect('db-to-db')} - > -
- - - -
-
-
DB → DB
-
데이터베이스 간 데이터 동기화
-
+ {/* 페이지네이션 */} + {totalPages > 1 && ( +
+ + +
+ {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { + const pageNum = i + 1; + return ( + + ); + })} +
+ + +
+ )} + + {/* 배치 타입 선택 모달 */} + {isBatchTypeModalOpen && ( +
+
+
+

배치 타입 선택

+ +
+ {/* DB → DB */} + + + {/* REST API → DB */} +
- {/* REST API → DB */} -
handleBatchTypeSelect('restapi-to-db')} - > -
- - - -
-
-
REST API → DB
-
REST API에서 데이터베이스로 데이터 수집
-
+
+
+
+
+ )} +
-
- -
- - -
- )} + {/* Scroll to Top 버튼 */} +
); } \ No newline at end of file diff --git a/frontend/app/(main)/admin/commonCode/page.tsx b/frontend/app/(main)/admin/commonCode/page.tsx index bdb435f7..9c1bf507 100644 --- a/frontend/app/(main)/admin/commonCode/page.tsx +++ b/frontend/app/(main)/admin/commonCode/page.tsx @@ -1,59 +1,49 @@ "use client"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { CodeCategoryPanel } from "@/components/admin/CodeCategoryPanel"; import { CodeDetailPanel } from "@/components/admin/CodeDetailPanel"; import { useSelectedCategory } from "@/hooks/useSelectedCategory"; -// import { useMultiLang } from "@/hooks/useMultiLang"; // 무한 루프 방지를 위해 임시 제거 +import { ScrollToTop } from "@/components/common/ScrollToTop"; export default function CommonCodeManagementPage() { - // const { getText } = useMultiLang(); // 무한 루프 방지를 위해 임시 제거 const { selectedCategoryCode, selectCategory } = useSelectedCategory(); return ( -
-
- {/* 페이지 제목 */} -
-
-

공통코드 관리

-

시스템에서 사용하는 공통코드를 관리합니다

+
+
+ {/* 페이지 헤더 */} +
+

공통코드 관리

+

시스템에서 사용하는 공통코드를 관리합니다

+
+ + {/* 메인 콘텐츠 - 좌우 레이아웃 */} +
+ {/* 좌측: 카테고리 패널 */} +
+
+

코드 카테고리

+ +
+
+ + {/* 우측: 코드 상세 패널 */} +
+
+

+ 코드 상세 정보 + {selectedCategoryCode && ( + ({selectedCategoryCode}) + )} +

+ +
- - {/* 메인 콘텐츠 */} - {/* 반응형 레이아웃: PC는 가로, 모바일은 세로 */} -
- {/* 카테고리 패널 - PC에서 좌측 고정 너비, 모바일에서 전체 너비 */} -
- - - 📂 코드 카테고리 - - - - - -
- - {/* 코드 상세 패널 - PC에서 나머지 공간, 모바일에서 전체 너비 */} -
- - - - 📋 코드 상세 정보 - {selectedCategoryCode && ( - ({selectedCategoryCode}) - )} - - - - - - -
-
+ + {/* Scroll to Top 버튼 */} +
); } diff --git a/frontend/app/(main)/admin/company/page.tsx b/frontend/app/(main)/admin/company/page.tsx index c24a3e10..c24afc7a 100644 --- a/frontend/app/(main)/admin/company/page.tsx +++ b/frontend/app/(main)/admin/company/page.tsx @@ -1,21 +1,25 @@ import { CompanyManagement } from "@/components/admin/CompanyManagement"; +import { ScrollToTop } from "@/components/common/ScrollToTop"; /** * 회사 관리 페이지 */ export default function CompanyPage() { return ( -
-
- {/* 페이지 제목 */} -
-
-

회사 관리

-

시스템에서 사용하는 회사 정보를 관리합니다

-
+
+
+ {/* 페이지 헤더 */} +
+

회사 관리

+

시스템에서 사용하는 회사 정보를 관리합니다

+ + {/* 메인 컨텐츠 */}
+ + {/* Scroll to Top 버튼 */} +
); } diff --git a/frontend/app/(main)/admin/dashboard/page.tsx b/frontend/app/(main)/admin/dashboard/page.tsx index d1ca6125..16e2ed6a 100644 --- a/frontend/app/(main)/admin/dashboard/page.tsx +++ b/frontend/app/(main)/admin/dashboard/page.tsx @@ -1,13 +1,11 @@ "use client"; -import React, { useState, useEffect } from "react"; +import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; import { dashboardApi } from "@/lib/api/dashboard"; import { Dashboard } from "@/lib/api/dashboard"; import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; -import { Badge } from "@/components/ui/badge"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { DropdownMenu, @@ -25,8 +23,9 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { Plus, Search, MoreVertical, Edit, Trash2, Copy, CheckCircle2 } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; +import { Pagination, PaginationInfo } from "@/components/common/Pagination"; +import { Plus, Search, Edit, Trash2, Copy, MoreVertical } from "lucide-react"; /** * 대시보드 관리 페이지 @@ -35,27 +34,38 @@ import { Plus, Search, MoreVertical, Edit, Trash2, Copy, CheckCircle2 } from "lu */ export default function DashboardListPage() { const router = useRouter(); + const { toast } = useToast(); const [dashboards, setDashboards] = useState([]); const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(""); - const [error, setError] = useState(null); + + // 페이지네이션 상태 + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [totalCount, setTotalCount] = useState(0); // 모달 상태 const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null); - const [successDialogOpen, setSuccessDialogOpen] = useState(false); - const [successMessage, setSuccessMessage] = useState(""); // 대시보드 목록 로드 const loadDashboards = async () => { try { setLoading(true); - setError(null); - const result = await dashboardApi.getMyDashboards({ search: searchTerm }); + const result = await dashboardApi.getMyDashboards({ + search: searchTerm, + page: currentPage, + limit: pageSize, + }); setDashboards(result.dashboards); + setTotalCount(result.pagination.total); } catch (err) { console.error("Failed to load dashboards:", err); - setError("대시보드 목록을 불러오는데 실패했습니다."); + toast({ + title: "오류", + description: "대시보드 목록을 불러오는데 실패했습니다.", + variant: "destructive", + }); } finally { setLoading(false); } @@ -63,7 +73,29 @@ export default function DashboardListPage() { useEffect(() => { loadDashboards(); - }, [searchTerm]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchTerm, currentPage, pageSize]); + + // 페이지네이션 정보 계산 + const paginationInfo: PaginationInfo = { + currentPage, + totalPages: Math.ceil(totalCount / pageSize), + totalItems: totalCount, + itemsPerPage: pageSize, + startItem: totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1, + endItem: Math.min(currentPage * pageSize, totalCount), + }; + + // 페이지 변경 핸들러 + const handlePageChange = (page: number) => { + setCurrentPage(page); + }; + + // 페이지 크기 변경 핸들러 + const handlePageSizeChange = (size: number) => { + setPageSize(size); + setCurrentPage(1); // 페이지 크기 변경 시 첫 페이지로 + }; // 대시보드 삭제 확인 모달 열기 const handleDeleteClick = (id: string, title: string) => { @@ -79,37 +111,48 @@ export default function DashboardListPage() { await dashboardApi.deleteDashboard(deleteTarget.id); setDeleteDialogOpen(false); setDeleteTarget(null); - setSuccessMessage("대시보드가 삭제되었습니다."); - setSuccessDialogOpen(true); + toast({ + title: "성공", + description: "대시보드가 삭제되었습니다.", + }); loadDashboards(); } catch (err) { console.error("Failed to delete dashboard:", err); setDeleteDialogOpen(false); - setError("대시보드 삭제에 실패했습니다."); + toast({ + title: "오류", + description: "대시보드 삭제에 실패했습니다.", + variant: "destructive", + }); } }; // 대시보드 복사 const handleCopy = async (dashboard: Dashboard) => { try { - // 전체 대시보드 정보(요소 포함)를 가져오기 const fullDashboard = await dashboardApi.getDashboard(dashboard.id); - const newDashboard = await dashboardApi.createDashboard({ + await dashboardApi.createDashboard({ title: `${fullDashboard.title} (복사본)`, description: fullDashboard.description, elements: fullDashboard.elements || [], isPublic: false, tags: fullDashboard.tags, category: fullDashboard.category, - settings: (fullDashboard as any).settings, // 해상도와 배경색 설정도 복사 + settings: fullDashboard.settings as { resolution?: string; backgroundColor?: string }, + }); + toast({ + title: "성공", + description: "대시보드가 복사되었습니다.", }); - setSuccessMessage("대시보드가 복사되었습니다."); - setSuccessDialogOpen(true); loadDashboards(); } catch (err) { console.error("Failed to copy dashboard:", err); - setError("대시보드 복사에 실패했습니다."); + toast({ + title: "오류", + description: "대시보드 복사에 실패했습니다.", + variant: "destructive", + }); } }; @@ -119,109 +162,99 @@ export default function DashboardListPage() { year: "numeric", month: "2-digit", day: "2-digit", - hour: "2-digit", - minute: "2-digit", }); }; if (loading) { return ( -
+
-
로딩 중...
-
대시보드 목록을 불러오고 있습니다
+
로딩 중...
+
대시보드 목록을 불러오고 있습니다
); } return ( -
-
- {/* 헤더 */} -
-

대시보드 관리

-

대시보드를 생성하고 관리할 수 있습니다

+
+
+ {/* 페이지 헤더 */} +
+

대시보드 관리

+

대시보드를 생성하고 관리할 수 있습니다

- {/* 액션 바 */} -
-
- + {/* 검색 및 액션 */} +
+
+ setSearchTerm(e.target.value)} - className="pl-9" + className="h-10 pl-10 text-sm" />
-
- {/* 에러 메시지 */} - {error && ( - -

{error}

-
- )} - {/* 대시보드 목록 */} {dashboards.length === 0 ? ( - -
- +
+
+

대시보드가 없습니다

-

대시보드가 없습니다

-

첫 번째 대시보드를 생성하여 데이터 시각화를 시작하세요

- - +
) : ( - +
- - 제목 - 설명 - 생성일 - 수정일 - 작업 + + 제목 + 설명 + 생성일 + 수정일 + 작업 {dashboards.map((dashboard) => ( - - {dashboard.title} - + + {dashboard.title} + {dashboard.description || "-"} - {formatDate(dashboard.createdAt)} - {formatDate(dashboard.updatedAt)} - + + {formatDate(dashboard.createdAt)} + + + {formatDate(dashboard.updatedAt)} + + - router.push(`/admin/dashboard/edit/${dashboard.id}`)} - className="gap-2" + className="gap-2 text-sm" > 편집 - handleCopy(dashboard)} className="gap-2"> + handleCopy(dashboard)} className="gap-2 text-sm"> 복사 handleDeleteClick(dashboard.id, dashboard.title)} - className="gap-2 text-red-600 focus:text-red-600" + className="text-destructive focus:text-destructive gap-2 text-sm" > 삭제 @@ -233,44 +266,42 @@ export default function DashboardListPage() { ))}
- +
+ )} + + {/* 페이지네이션 */} + {!loading && dashboards.length > 0 && ( + )}
{/* 삭제 확인 모달 */} - + - 대시보드 삭제 - + 대시보드 삭제 + "{deleteTarget?.title}" 대시보드를 삭제하시겠습니까?
이 작업은 되돌릴 수 없습니다.
- - 취소 - + + 취소 + 삭제
- - {/* 성공 모달 */} - - - -
- -
- 완료 - {successMessage} -
-
- -
-
-
); } diff --git a/frontend/app/(main)/admin/dataflow/page.tsx b/frontend/app/(main)/admin/dataflow/page.tsx index ff7e5aeb..f8a77e11 100644 --- a/frontend/app/(main)/admin/dataflow/page.tsx +++ b/frontend/app/(main)/admin/dataflow/page.tsx @@ -7,6 +7,7 @@ import { FlowEditor } from "@/components/dataflow/node-editor/FlowEditor"; import { useAuth } from "@/hooks/useAuth"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { ScrollToTop } from "@/components/common/ScrollToTop"; import { ArrowLeft } from "lucide-react"; type Step = "list" | "editor"; @@ -50,17 +51,17 @@ export default function DataFlowPage() { // 에디터 모드일 때는 레이아웃 없이 전체 화면 사용 if (isEditorMode) { return ( -
+
{/* 에디터 헤더 */} -
+
-

노드 플로우 에디터

-

+

노드 플로우 에디터

+

드래그 앤 드롭으로 데이터 제어 플로우를 시각적으로 설계합니다

@@ -76,19 +77,20 @@ export default function DataFlowPage() { } return ( -
-
- {/* 페이지 제목 */} -
-
-

제어 관리

-

노드 기반 데이터 플로우를 시각적으로 설계하고 관리합니다

-
+
+
+ {/* 페이지 헤더 */} +
+

제어 관리

+

노드 기반 데이터 플로우를 시각적으로 설계하고 관리합니다

{/* 플로우 목록 */}
+ + {/* Scroll to Top 버튼 */} +
); } diff --git a/frontend/app/(main)/admin/external-call-configs/page.tsx b/frontend/app/(main)/admin/external-call-configs/page.tsx index 805220ca..7e433ec7 100644 --- a/frontend/app/(main)/admin/external-call-configs/page.tsx +++ b/frontend/app/(main)/admin/external-call-configs/page.tsx @@ -161,205 +161,201 @@ export default function ExternalCallConfigsPage() { }; return ( -
-
- {/* 페이지 헤더 */} -
-
-

외부 호출 관리

-

Discord, Slack, 카카오톡 등 외부 호출 설정을 관리합니다.

+
+
+ {/* 페이지 헤더 */} +
+

외부 호출 관리

+

Discord, Slack, 카카오톡 등 외부 호출 설정을 관리합니다.

- -
- {/* 검색 및 필터 */} - - - - - 검색 및 필터 - - - - {/* 검색 */} -
-
- setSearchQuery(e.target.value)} - onKeyPress={handleSearchKeyPress} - /> + {/* 검색 및 필터 영역 */} +
+ {/* 첫 번째 줄: 검색 + 추가 버튼 */} +
+
+
+
+ + setSearchQuery(e.target.value)} + onKeyPress={handleSearchKeyPress} + className="h-10 pl-10 text-sm" + /> +
+
+
-
- {/* 필터 */} -
-
- - -
+ {/* 두 번째 줄: 필터 */} +
+ -
- - -
+ -
- - -
+
- - +
- {/* 설정 목록 */} - - - 외부 호출 설정 목록 - - + {/* 설정 목록 */} +
{loading ? ( // 로딩 상태 -
-
로딩 중...
+
+
로딩 중...
) : configs.length === 0 ? ( // 빈 상태 -
-
- -

등록된 외부 호출 설정이 없습니다.

-

새 외부 호출을 추가해보세요.

+
+
+

등록된 외부 호출 설정이 없습니다.

+

새 외부 호출을 추가해보세요.

) : ( // 설정 테이블 목록 - - 설정명 - 호출 타입 - API 타입 - 설명 - 상태 - 생성일 - 작업 + + 설정명 + 호출 타입 + API 타입 + 설명 + 상태 + 생성일 + 작업 {configs.map((config) => ( - - {config.config_name} - + + {config.config_name} + {getCallTypeLabel(config.call_type)} - + {config.api_type ? ( {getApiTypeLabel(config.api_type)} ) : ( - - + - )} - +
{config.description ? ( - + {config.description} ) : ( - - + - )}
- + {config.is_active === "Y" ? "활성" : "비활성"} - + {config.created_date ? new Date(config.created_date).toLocaleDateString() : "-"} - +
- -
@@ -368,8 +364,7 @@ export default function ExternalCallConfigsPage() {
)} - - +
{/* 외부 호출 설정 모달 */} - + - 외부 호출 설정 삭제 - + 외부 호출 설정 삭제 + "{configToDelete?.config_name}" 설정을 삭제하시겠습니까?
이 작업은 되돌릴 수 없습니다.
- - 취소 - + + + 취소 + + 삭제 diff --git a/frontend/app/(main)/admin/external-connections/page.tsx b/frontend/app/(main)/admin/external-connections/page.tsx index 802a2fea..3c80ac58 100644 --- a/frontend/app/(main)/admin/external-connections/page.tsx +++ b/frontend/app/(main)/admin/external-connections/page.tsx @@ -1,13 +1,14 @@ "use client"; import React, { useState, useEffect } from "react"; -import { Plus, Search, Pencil, Trash2, Database, Terminal } from "lucide-react"; +import { Plus, Search, Pencil, Trash2, Database, Terminal, Globe } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { AlertDialog, AlertDialogAction, @@ -27,6 +28,9 @@ import { } from "@/lib/api/externalDbConnection"; import { ExternalDbConnectionModal } from "@/components/admin/ExternalDbConnectionModal"; import { SqlQueryModal } from "@/components/admin/SqlQueryModal"; +import { RestApiConnectionList } from "@/components/admin/RestApiConnectionList"; + +type ConnectionTabType = "database" | "rest-api"; // DB 타입 매핑 const DB_TYPE_LABELS: Record = { @@ -47,6 +51,9 @@ const ACTIVE_STATUS_OPTIONS = [ export default function ExternalConnectionsPage() { const { toast } = useToast(); + // 탭 상태 + const [activeTab, setActiveTab] = useState("database"); + // 상태 관리 const [connections, setConnections] = useState([]); const [loading, setLoading] = useState(true); @@ -220,236 +227,246 @@ export default function ExternalConnectionsPage() { }; return ( -
-
- {/* 페이지 제목 */} -
-
-

외부 커넥션 관리

-

외부 데이터베이스 연결 정보를 관리합니다

-
+
+
+ {/* 페이지 헤더 */} +
+

외부 커넥션 관리

+

외부 데이터베이스 및 REST API 연결 정보를 관리합니다

- {/* 검색 및 필터 */} - - -
-
- {/* 검색 */} -
- - setSearchTerm(e.target.value)} - className="w-64 pl-10" - /> + {/* 탭 */} + setActiveTab(value as ConnectionTabType)}> + + + + 데이터베이스 연결 + + + + REST API 연결 + + + + {/* 데이터베이스 연결 탭 */} + + {/* 검색 및 필터 */} +
+
+ {/* 검색 */} +
+ + setSearchTerm(e.target.value)} + className="h-10 pl-10 text-sm" + /> +
+ + {/* DB 타입 필터 */} + + + {/* 활성 상태 필터 */} +
- {/* DB 타입 필터 */} - - - {/* 활성 상태 필터 */} - -
- - {/* 추가 버튼 */} - -
- - - - {/* 연결 목록 */} - {loading ? ( -
-
로딩 중...
-
- ) : connections.length === 0 ? ( - - -
- -

등록된 연결이 없습니다

-

새 외부 데이터베이스 연결을 추가해보세요.

-
-
-
- ) : ( - - - - - - 연결명 - DB 타입 - 호스트:포트 - 데이터베이스 - 사용자 - 상태 - 생성일 - 연결 테스트 - 작업 - - - - {connections.map((connection) => ( - - -
{connection.connection_name}
-
- - - {DB_TYPE_LABELS[connection.db_type] || connection.db_type} - - - - {connection.host}:{connection.port} - - {connection.database_name} - {connection.username} - - - {connection.is_active === "Y" ? "활성" : "비활성"} - - - - {connection.created_date ? new Date(connection.created_date).toLocaleDateString() : "N/A"} - - -
- - {testResults.has(connection.id!) && ( - - {testResults.get(connection.id!) ? "성공" : "실패"} - - )} -
-
- -
- - - -
-
-
- ))} -
-
-
-
- )} - {/* 연결 설정 모달 */} - {isModalOpen && ( - type.value !== "ALL")} - /> - )} + {/* 연결 목록 */} + {loading ? ( +
+
로딩 중...
+
+ ) : connections.length === 0 ? ( +
+
+

등록된 연결이 없습니다

+
+
+ ) : ( +
+ + + + 연결명 + DB 타입 + 호스트:포트 + 데이터베이스 + 사용자 + 상태 + 생성일 + 연결 테스트 + 작업 + + + + {connections.map((connection) => ( + + +
{connection.connection_name}
+
+ + + {DB_TYPE_LABELS[connection.db_type] || connection.db_type} + + + + {connection.host}:{connection.port} + + {connection.database_name} + {connection.username} + + + {connection.is_active === "Y" ? "활성" : "비활성"} + + + + {connection.created_date ? new Date(connection.created_date).toLocaleDateString() : "N/A"} + + +
+ + {testResults.has(connection.id!) && ( + + {testResults.get(connection.id!) ? "성공" : "실패"} + + )} +
+
+ +
+ + + +
+
+
+ ))} +
+
+
+ )} - {/* 삭제 확인 다이얼로그 */} - - - - 연결 삭제 확인 - - "{connectionToDelete?.connection_name}" 연결을 삭제하시겠습니까? -
- 이 작업은 되돌릴 수 없습니다. -
-
- - 취소 - - 삭제 - - -
-
+ {/* 연결 설정 모달 */} + {isModalOpen && ( + type.value !== "ALL")} + /> + )} - {/* SQL 쿼리 모달 */} - {selectedConnection && ( - { - setSqlModalOpen(false); - setSelectedConnection(null); - }} - connectionId={selectedConnection.id!} - connectionName={selectedConnection.connection_name} - /> - )} + {/* 삭제 확인 다이얼로그 */} + + + + 연결 삭제 확인 + + "{connectionToDelete?.connection_name}" 연결을 삭제하시겠습니까? +
+ 이 작업은 되돌릴 수 없습니다. +
+
+ + + 취소 + + + 삭제 + + +
+
+ + {/* SQL 쿼리 모달 */} + {selectedConnection && ( + { + setSqlModalOpen(false); + setSelectedConnection(null); + }} + connectionId={selectedConnection.id!} + connectionName={selectedConnection.connection_name} + /> + )} + + + {/* REST API 연결 탭 */} + + + +
); diff --git a/frontend/app/(main)/admin/flow-management/[id]/page.tsx b/frontend/app/(main)/admin/flow-management/[id]/page.tsx index 77d42718..a311bc63 100644 --- a/frontend/app/(main)/admin/flow-management/[id]/page.tsx +++ b/frontend/app/(main)/admin/flow-management/[id]/page.tsx @@ -73,7 +73,9 @@ export default function FlowEditorPage() { // 플로우 정의 로드 const flowRes = await getFlowDefinition(flowId); if (flowRes.success && flowRes.data) { - setFlowDefinition(flowRes.data); + console.log("🔍 Flow Definition loaded:", flowRes.data); + console.log("📋 Table Name:", flowRes.data.definition?.tableName); + setFlowDefinition(flowRes.data.definition); } // 단계 로드 @@ -314,6 +316,9 @@ export default function FlowEditorPage() { setSelectedStep(null)} onUpdate={loadFlowData} /> diff --git a/frontend/app/(main)/admin/flow-management/page.tsx b/frontend/app/(main)/admin/flow-management/page.tsx index 999fb6fa..e8166662 100644 --- a/frontend/app/(main)/admin/flow-management/page.tsx +++ b/frontend/app/(main)/admin/flow-management/page.tsx @@ -9,9 +9,8 @@ import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; -import { Plus, Edit2, Trash2, Play, Workflow, Table, Calendar, User } from "lucide-react"; +import { Plus, Edit2, Trash2, Workflow, Table, Calendar, User, Check, ChevronsUpDown } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Dialog, @@ -27,6 +26,12 @@ import { Textarea } from "@/components/ui/textarea"; import { useToast } from "@/hooks/use-toast"; import { getFlowDefinitions, createFlowDefinition, deleteFlowDefinition } from "@/lib/api/flow"; import { FlowDefinition } from "@/types/flow"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { cn } from "@/lib/utils"; +import { tableManagementApi } from "@/lib/api/tableManagement"; +import { ScrollToTop } from "@/components/common/ScrollToTop"; export default function FlowManagementPage() { const router = useRouter(); @@ -39,6 +44,19 @@ export default function FlowManagementPage() { const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [selectedFlow, setSelectedFlow] = useState(null); + // 테이블 목록 관련 상태 + const [tableList, setTableList] = useState>( + [], + ); + const [loadingTables, setLoadingTables] = useState(false); + const [openTableCombobox, setOpenTableCombobox] = useState(false); + const [selectedDbSource, setSelectedDbSource] = useState<"internal" | number>("internal"); // "internal" 또는 외부 DB connection ID + const [externalConnections, setExternalConnections] = useState< + Array<{ id: number; connection_name: string; db_type: string }> + >([]); + const [externalTableList, setExternalTableList] = useState([]); + const [loadingExternalTables, setLoadingExternalTables] = useState(false); + // 생성 폼 상태 const [formData, setFormData] = useState({ name: "", @@ -60,10 +78,10 @@ export default function FlowManagementPage() { variant: "destructive", }); } - } catch (error: any) { + } catch (error) { toast({ title: "오류 발생", - description: error.message, + description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.", variant: "destructive", }); } finally { @@ -73,11 +91,113 @@ export default function FlowManagementPage() { useEffect(() => { loadFlows(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // 테이블 목록 로드 (내부 DB) + useEffect(() => { + const loadTables = async () => { + try { + setLoadingTables(true); + const response = await tableManagementApi.getTableList(); + if (response.success && response.data) { + setTableList(response.data); + } + } catch (error) { + console.error("Failed to load tables:", error); + } finally { + setLoadingTables(false); + } + }; + loadTables(); + }, []); + + // 외부 DB 연결 목록 로드 + useEffect(() => { + const loadConnections = async () => { + try { + const token = localStorage.getItem("authToken"); + if (!token) { + console.warn("No auth token found"); + return; + } + + const response = await fetch("/api/external-db-connections/control/active", { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (response && response.ok) { + const data = await response.json(); + if (data.success && data.data) { + // 메인 데이터베이스(현재 시스템) 제외 - connection_name에 "메인" 또는 "현재 시스템"이 포함된 것 필터링 + const filtered = data.data.filter( + (conn: { connection_name: string }) => + !conn.connection_name.includes("메인") && !conn.connection_name.includes("현재 시스템"), + ); + setExternalConnections(filtered); + } + } + } catch (error) { + console.error("Failed to load external connections:", error); + setExternalConnections([]); + } + }; + loadConnections(); + }, []); + + // 외부 DB 테이블 목록 로드 + useEffect(() => { + if (selectedDbSource === "internal" || !selectedDbSource) { + setExternalTableList([]); + return; + } + + const loadExternalTables = async () => { + try { + setLoadingExternalTables(true); + const token = localStorage.getItem("authToken"); + + const response = await fetch(`/api/multi-connection/connections/${selectedDbSource}/tables`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (response && response.ok) { + const data = await response.json(); + if (data.success && data.data) { + const tables = Array.isArray(data.data) ? data.data : []; + const tableNames = tables + .map((t: string | { tableName?: string; table_name?: string; tablename?: string; name?: string }) => + typeof t === "string" ? t : t.tableName || t.table_name || t.tablename || t.name, + ) + .filter(Boolean); + setExternalTableList(tableNames); + } else { + setExternalTableList([]); + } + } else { + setExternalTableList([]); + } + } catch (error) { + console.error("외부 DB 테이블 목록 조회 오류:", error); + setExternalTableList([]); + } finally { + setLoadingExternalTables(false); + } + }; + + loadExternalTables(); + }, [selectedDbSource]); + // 플로우 생성 const handleCreate = async () => { + console.log("🚀 handleCreate called with formData:", formData); + if (!formData.name || !formData.tableName) { + console.log("❌ Validation failed:", { name: formData.name, tableName: formData.tableName }); toast({ title: "입력 오류", description: "플로우 이름과 테이블 이름은 필수입니다.", @@ -87,7 +207,15 @@ export default function FlowManagementPage() { } try { - const response = await createFlowDefinition(formData); + // DB 소스 정보 추가 + const requestData = { + ...formData, + dbSourceType: selectedDbSource === "internal" ? "internal" : "external", + dbConnectionId: selectedDbSource === "internal" ? undefined : Number(selectedDbSource), + }; + + console.log("✅ Calling createFlowDefinition with:", requestData); + const response = await createFlowDefinition(requestData); if (response.success && response.data) { toast({ title: "생성 완료", @@ -95,6 +223,7 @@ export default function FlowManagementPage() { }); setIsCreateDialogOpen(false); setFormData({ name: "", description: "", tableName: "" }); + setSelectedDbSource("internal"); loadFlows(); } else { toast({ @@ -103,10 +232,10 @@ export default function FlowManagementPage() { variant: "destructive", }); } - } catch (error: any) { + } catch (error) { toast({ title: "오류 발생", - description: error.message, + description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.", variant: "destructive", }); } @@ -133,10 +262,10 @@ export default function FlowManagementPage() { variant: "destructive", }); } - } catch (error: any) { + } catch (error) { toast({ title: "오류 발생", - description: error.message, + description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.", variant: "destructive", }); } @@ -148,213 +277,342 @@ export default function FlowManagementPage() { }; return ( -
- {/* 헤더 */} -
-
-

- - 플로우 관리 -

-

업무 프로세스 플로우를 생성하고 관리합니다

+
+
+ {/* 페이지 헤더 */} +
+

플로우 관리

+

업무 프로세스 플로우를 생성하고 관리합니다

- -
- {/* 플로우 카드 목록 */} - {loading ? ( -
-

로딩 중...

+ {/* 액션 버튼 영역 */} +
+
- ) : flows.length === 0 ? ( - - - -

생성된 플로우가 없습니다

- -
-
- ) : ( -
- {flows.map((flow) => ( - handleEdit(flow.id)} - > - -
+ + {/* 플로우 카드 목록 */} + {loading ? ( +
+ {Array.from({ length: 6 }).map((_, index) => ( +
+
+
+
+
+
+
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+
+
+ ))} +
+
+
+
+
+
+ ))} +
+ ) : flows.length === 0 ? ( +
+
+
+ +
+

생성된 플로우가 없습니다

+

+ 새 플로우를 생성하여 업무 프로세스를 관리해보세요. +

+ +
+
+ ) : ( +
+ {flows.map((flow) => ( +
handleEdit(flow.id)} + > + {/* 헤더 */} +
- - {flow.name} +
+

{flow.name}

{flow.isActive && ( - - 활성 - + 활성 )} - - - {flow.description || "설명 없음"} - -
-
- - -
-
- - {flow.tableName} - -
- - 생성자: {flow.createdBy} -
-
- - {new Date(flow.updatedAt).toLocaleDateString("ko-KR")} +
+

{flow.description || "설명 없음"}

-
+ {/* 정보 */} +
+
+
+ {flow.tableName} + +
+ + 생성자: {flow.createdBy} +
+
+ + + {new Date(flow.updatedAt).toLocaleDateString("ko-KR")} + +
+ + + {/* 액션 */} +
- - - ))} - - )} - - {/* 생성 다이얼로그 */} - - - - 새 플로우 생성 - - 새로운 업무 프로세스 플로우를 생성합니다 - - - -
-
- - setFormData({ ...formData, name: e.target.value })} - placeholder="예: 제품 수명주기 관리" - className="h-8 text-xs sm:h-10 sm:text-sm" - /> -
- -
- - setFormData({ ...formData, tableName: e.target.value })} - placeholder="예: products" - className="h-8 text-xs sm:h-10 sm:text-sm" - /> -

- 플로우가 관리할 데이터 테이블 이름을 입력하세요 -

-
- -
- -