From 76d91efcb8898919626e1b8b8692d28249c3cf39 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 8 Jul 2026 20:28:24 +0900 Subject: [PATCH] 1 --- .agent/FOLLOWUP_dashboard_fixes.md | 30 ++ .agent/backend.md | 30 ++ .agent/frontend.md | 22 +- .agent/plan_dashboard_management.md | 379 ++++++++++++++++++++++ .agent/validation_dashboard_management.md | 60 ++++ B01_Dashboard/B01_Dashboard_Api_Fetch.ts | 91 +++++- B01_Dashboard/B01_Dashboard_Repository.py | 224 +++++++++++++ B01_Dashboard/B01_Dashboard_Router.py | 185 ++++++++++- B01_Dashboard/B01_Dashboard_Schema.py | 23 ++ CLAUDE.md | 4 +- db_management/004_dashboard.sql | 23 ++ 11 files changed, 1064 insertions(+), 7 deletions(-) create mode 100644 .agent/FOLLOWUP_dashboard_fixes.md create mode 100644 .agent/plan_dashboard_management.md create mode 100644 .agent/validation_dashboard_management.md diff --git a/.agent/FOLLOWUP_dashboard_fixes.md b/.agent/FOLLOWUP_dashboard_fixes.md new file mode 100644 index 0000000..5a43caa --- /dev/null +++ b/.agent/FOLLOWUP_dashboard_fixes.md @@ -0,0 +1,30 @@ +# 대시보드 검증 후속 조치 계획서 (FOLLOWUP Dashboard Fixes) + +본 문서는 대시보드 관리 기능 검증 과정에서 확인된 계획서 불일치 항목 및 오차를 해결하기 위한 백엔드 및 프론트엔드 후속 수정 내역을 기술합니다. + +--- + +## 1. 백엔드 권한 제어 수정 사항 + +### 1.1 회사 멤버(사용자) 조회 권한 완화 +- **이슈:** `GET /api/dashboard/admin/members` API가 `require_company_admin` 데코레이터로 보호되어 있어, 일반 `USER`가 자기 회사의 팀원 목록을 조회하지 못하고 403 에러가 발생함. +- **수정 계획:** + - `admin_members` 엔드포인트의 의존성을 `Depends(verify_session)`으로 완화. + - 함수 내부에서 호출자의 `company_id`가 존재하는지 검증한 후, 해당 회사의 멤버 목록만 조회하도록 변경. + +### 1.2 자동화 설계 로직 조회 권한 제한 (기획 확인에 따른 변경) +- **이슈:** `GET /projects/{project_id}/automations` API가 `_can_edit_project`를 사용하고 있어 `USER`에게 노출됨. 계획서(USER ❌ 조회 불가) 기준에 맞게 통제가 필요함. +- **수정 계획:** + - `dashboard_project_automations`에서 `USER` 권한인 경우 조회를 차단하고 403 에러를 반환하도록 로직 적용. + +--- + +## 2. 프론트엔드 UI/UX 및 모달 구현 사항 + +### 2.1 다국어(i18n) 키 추가 +- `ui_template_locale.ts` 파일 하단에 계획서 8번의 다국어 문구 키들(`B01_Dashboard_EditProject` 등)을 추가 반영. + +### 2.2 누락된 관리자 모달 및 기능 구현 +- **프로젝트 수정/삭제 모달:** `openEditProjectModal`, `openDeleteProjectModal` 구현 및 조건부 노출. +- **사용자 관리 모달:** `openEditUserModal`, `openChangeRoleModal`, `openDeleteUserModal` 구현. +- **자동화 설계 로직 관리:** 프로젝트 상세 화면 내에서 자동화 목록 조회 및 제어 모달 연동. diff --git a/.agent/backend.md b/.agent/backend.md index a6e5ed9..1598afc 100644 --- a/.agent/backend.md +++ b/.agent/backend.md @@ -199,6 +199,36 @@ - `ADMIN`: 회사 관리자 (회사 내 관리권 한정) - `USER`: 일반 사용자 +### 6.9 대시보드 권한 검증 및 감시 로깅 (B01_Dashboard Authorization & Audit) +* **권한 검증 헬퍼 필수:** `.agent/plan_dashboard_management.md` 섹션 9의 Python 헬퍼 함수 구현 + - `can_edit_project(user, project)` - 프로젝트 수정 권한 + - `can_delete_project(user)` - 프로젝트 삭제 권한 (SYSTEM_ADMIN만) + - `can_change_role(user, target_user, new_role)` - 역할 변경 권한 + - ADMIN이 SYSTEM_ADMIN으로 변경 시도 시 즉시 False 반환 + - ADMIN은 USER ↔ ADMIN만 변경 가능 (같은 회사만) + - `can_manage_automation(user, automation)` - 자동화 로직 관리 권한 (USER 차단) + - `is_last_admin(company_id, exclude_user_id)` - 회사의 마지막 ADMIN 확인 + +* **백엔드 권한 재검증 필수:** 모든 수정/삭제/역할변경 API에서 권한 재검증 + - 클라이언트 UI 신뢰 금지 (프론트엔드는 UI만 제어) + - 권한 없는 사용자가 API 직접 호출 시도 → 403 Forbidden + - 권한 오류 메시지: `{"status": "error", "message": "Access denied"}` + +* **감시 로깅 (audit_logs):** 다음 행동들을 기록 + - 프로젝트 삭제: `action="DELETE_PROJECT"`, `resource_type="project"` + - 사용자 역할 변경: `action="CHANGE_ROLE_TO_{NEW_ROLE}"`, `resource_type="user"` + - 사용자 삭제: `action="DELETE_USER"`, `resource_type="user"` + - 자동화 로직 삭제: `action="DELETE_AUTOMATION"`, `resource_type="automation"` + +* **USER 자동화 조회 권한 차단:** `/api/projects/{pid}/automations` GET + - 요청 사용자의 role이 USER인 경우 403 Forbidden 반환 + - 메시지: `"Automation logic viewing is not allowed for users"` + +* **마지막 ADMIN 보호:** 사용자 삭제 API에서 체크 + - `is_last_admin(company_id, exclude_user_id=user_id)` 호출 + - True 반환 시 400 Bad Request: `"Cannot delete the last admin of the company"` + - 역할 변경 API에서도 동일 로직 적용 + **마스터 플래그 (users.is_master)** - `TRUE`: 회사 생성자 또는 ADMIN 역할 (팀원 승인/거부 권한) - `FALSE`: 일반 팀원 diff --git a/.agent/frontend.md b/.agent/frontend.md index 8253aa2..c854565 100644 --- a/.agent/frontend.md +++ b/.agent/frontend.md @@ -97,4 +97,24 @@ export const ui_locales = { - 회원가입 후 이메일 인증 완료: `users.status = 'NO_COMPANY'` (로그인 가능, 회사 미연결) - 회사 생성: `users.status = 'ACTIVE'`, `company_id` 세팅, `role = 'ADMIN'`, `is_master = TRUE` - 회사 참여 신청: `users.status = 'PENDING'` (관리자 승인 대기) - - 관리자 승인: `users.status = 'ACTIVE'`, `company_id` 세팅 \ No newline at end of file + - 관리자 승인: `users.status = 'ACTIVE'`, `company_id` 세팅 + +### 5.3 대시보드 권한 기반 UI 제어 (B01_Dashboard) +* **권한 검증 헬퍼 필수:** `.agent/plan_dashboard_management.md` 섹션 9의 헬퍼 함수 참고 + - `canEditProject(user, project)` - 프로젝트 수정 권한 + - `canDeleteProject(user)` - 프로젝트 삭제 권한 (SYSTEM_ADMIN만) + - `canAddUser(user)` - 사용자 추가 권한 + - `canChangeRole(user, targetUser)` - 역할 변경 권한 (ADMIN: USER↔ADMIN만, SYSTEM_ADMIN 제외) + - `canDeleteUser(user, targetUser)` - 사용자 삭제 권한 (마지막 ADMIN 보호) + - `canManageAutomation(user, automation)` - 자동화 로직 관리 권한 + - `isLastAdmin(company, excludeUserId)` - 회사의 마지막 ADMIN 확인 + +* **UI 컴포넌트 구현:** 권한에 따라 버튼/필드 표시/비활성화 + - 프로젝트: 수정 모달, 삭제 확인 모달 (삭제는 SYSTEM_ADMIN만) + - 사용자: 추가 모달 (가입 계정 목록), 수정 모달, 역할변경 모달, 삭제 확인 모달 + - 자동화 로직: 생성/수정/삭제 모달 (ADMIN/SYSTEM_ADMIN만) + - 모든 삭제 모달에 경고 메시지 포함 (되돌릴 수 없음) + +* **다국어 반영:** 계획서 8장의 모든 키를 `ui_template_locale.ts`에 추가 후 컴포넌트에서 `L(키)` 사용 + - 모달 제목, 버튼 레이블, 확인 메시지, 오류 메시지 모두 다국어 처리 + - 하드코딩된 문구 절대 금지 \ No newline at end of file diff --git a/.agent/plan_dashboard_management.md b/.agent/plan_dashboard_management.md new file mode 100644 index 0000000..0c7804f --- /dev/null +++ b/.agent/plan_dashboard_management.md @@ -0,0 +1,379 @@ +# 대시보드 프로젝트/사용자/회사 관리 기능 계획서 + +**버전:** 2.1 +**작성일:** 2026-07-08 +**상태:** 승인 대기 + +--- + +## 1. 역할 정의 + +### 1.1 SYSTEM_ADMIN (시스템 관리자) +**대상:** 서비스 개발사 (당신, 우리 회사 사람들) + +**책임:** +- 서비스 배포 및 운영 +- 자동화 설계 로직 제어 +- 타사(고객사) 트러블슈팅 +- 전체 시스템 모니터링 + +**권한:** +- ✅ 모든 회사/프로젝트 관리 (조회, 추가, 수정, 삭제) +- ✅ 모든 사용자 관리 (조회, 추가, 수정, 삭제) +- ✅ 모든 사용자 역할 변경 +- ✅ 자동화 설계 로직 제어 +- ✅ 시스템 리소스 모니터링 +- ⚠️ 역할 변경은 DB 직접 수정만 가능 (UI 제공 안함) + +--- + +### 1.2 ADMIN (고객사 관리자) +**대상:** 계약된 고객사의 관리자 + +**책임:** +- 고객사 내부 사용자 관리 +- 자동화 설계 로직 제어 (계약된 범위) +- 프로젝트 진행 관리 + +**권한:** +- ✅ 자신 회사의 프로젝트 조회/수정 +- ✅ 자신 회사의 사용자 추가/수정/삭제 +- ✅ 사용자 역할 변경 (USER ↔ ADMIN만, SYSTEM_ADMIN 제외) +- ✅ **자동화 설계 로직 제어** (계약된 범위) +- ❌ 프로젝트 삭제 불가 (SYSTEM_ADMIN만) +- ❌ 타사 데이터 접근 불가 +- ❌ 시스템 리소스 모니터링 불가 + +--- + +### 1.3 USER (일반 사용자) +**대상:** 고객사에 소속된 프로젝트 진행자 + +**책임:** +- 할당된 프로젝트 작업 수행 + +**권한:** +- ✅ 자신 회사의 프로젝트 조회/수정 (진행 데이터 입력) +- ✅ 자신 회사의 멤버 조회 +- ✅ 자신의 프로필 정보 수정 +- ❌ 프로젝트 추가/삭제 불가 +- ❌ 사용자 관리 불가 +- ❌ 자동화 설계 로직 제어 불가 + +--- + +## 2. 권한 기반 기능 매트릭스 + +### 2.1 프로젝트 관리 + +| 기능 | SYSTEM_ADMIN | ADMIN | USER | 비고 | +|------|:---:|:---:|:---:|------| +| 조회 | ✅ 전체 | ✅ 회사 | ✅ 회사 | 기본 기능 | +| 추가 | ✅ | ✅ | ❌ | — | +| 수정 | ✅ 전체 | ✅ 회사 | ✅ 회사 | USER: 진행 데이터만 | +| 삭제 | ✅ | ❌ | ❌ | SYSTEM_ADMIN만 | + +--- + +### 2.2 사용자 관리 + +| 기능 | SYSTEM_ADMIN | ADMIN | USER | 비고 | +|------|:---:|:---:|:---:|------| +| 조회 | ✅ 전체 | ✅ 회사 | ✅ 회사 | 회사 멤버 리스트 | +| 추가 | ✅ | ✅ | ❌ | 가입 계정 기반 추가 | +| 수정 정보 | ✅ 전체 | ✅ 전체 | ✅ 자신만 | 프로필 정보 수정 | +| 역할 변경 | ✅ DB만 | ✅ USER↔ADMIN | ❌ | SYSTEM_ADMIN 변경 차단 | +| 삭제 | ✅ | ✅ 회사원 | ❌ | 마지막 ADMIN 보호 | + +--- + +### 2.3 회사 관리 + +| 기능 | SYSTEM_ADMIN | 비고 | +|------|:---:|------| +| 조회 | ✅ 전체 | — | +| 추가 | ✅ | — | +| 수정 | ✅ | — | +| 삭제 | ✅ | — | + +--- + +### 2.4 자동화 설계 로직 제어 + +| 기능 | SYSTEM_ADMIN | ADMIN | USER | 비고 | +|------|:---:|:---:|:---:|------| +| 조회 | ✅ 전체 | ✅ 회사 | ❌ | 프로젝트 기반 조회 | +| 생성/수정 | ✅ | ✅ 회사 | ❌ | 계약된 범위만 | +| 실행 | ✅ | ✅ | ❌ | — | +| 삭제 | ✅ | ✅ | ❌ | 계약된 범위만 | + +--- + +## 3. 상세 기능 명세 + +### 3.1 프로젝트 관리 + +#### 📝 프로젝트 수정 +- **SYSTEM_ADMIN:** 모든 필드 수정 가능 +- **ADMIN:** 자신 회사의 프로젝트만 수정 +- **USER:** 자신 회사의 프로젝트만 수정 (진행 관련 데이터: 좌표, 설정값 등) + +#### ❌ 프로젝트 삭제 +- **SYSTEM_ADMIN:** 모든 프로젝트 삭제 가능 +- **ADMIN/USER:** 삭제 불가 +- 경고 모달: "되돌릴 수 없습니다" +- 감시 로깅: `audit_logs` 기록 + +--- + +### 3.2 사용자 관리 + +#### ➕ 사용자 추가 (가입 계정 기반) +- **Flow:** 가입된 사용자 리스트 표시 → 선택 → 회사에 추가 +- **권한:** SYSTEM_ADMIN, ADMIN만 가능 +- 추가 후 상태: `ACTIVE` + +#### ✏️ 사용자 정보 수정 +- **SYSTEM_ADMIN:** 모든 필드 수정 +- **ADMIN:** 자신 회사 사용자의 모든 필드 수정 +- **USER:** 자신의 프로필만 수정 (이름, 직책, 부서, 연락처) + +#### 🔄 역할 변경 +- **SYSTEM_ADMIN:** 모든 역할 변경 가능 (DB 직접, UI 미제공) +- **ADMIN:** USER ↔ ADMIN만 변경 가능 +- **USER:** 역할 변경 불가 +- **🚫 제약:** SYSTEM_ADMIN으로 변경 시도 시 백엔드에서 차단 + +#### ❌ 사용자 삭제 +- **SYSTEM_ADMIN:** 모든 사용자 삭제 가능 +- **ADMIN:** 자신 회사 사용자만 삭제 가능 (마지막 ADMIN 보호) +- **USER:** 사용자 삭제 불가 +- 경고 모달: 마지막 ADMIN 삭제 방지 + +--- + +### 3.3 자동화 설계 로직 제어 + +#### 📋 조회 +- **SYSTEM_ADMIN:** 모든 자동화 설계 로직 조회 +- **ADMIN:** 자신 회사의 프로젝트에 할당된 자동화 로직만 조회 +- **USER:** 조회 불가 + +#### ➕ 생성/수정 +- **SYSTEM_ADMIN:** 모든 자동화 로직 생성/수정 가능 +- **ADMIN:** 자신 회사의 프로젝트에 할당 가능한 범위 내에서만 생성/수정 +- **USER:** 불가 + +#### ▶️ 실행 +- **SYSTEM_ADMIN:** 모든 자동화 로직 실행 가능 +- **ADMIN:** 자신 회사의 프로젝트에 할당된 자동화 로직만 실행 +- **USER:** 불가 + +#### ❌ 삭제 +- **SYSTEM_ADMIN:** 모든 자동화 로직 삭제 가능 +- **ADMIN:** 자신 회사에서 생성한 자동화 로직만 삭제 +- **USER:** 불가 + +--- + +### 3.4 테이블 및 UI 개선 + +#### 프로젝트 테이블 +- 프로젝트명, 지역, 진행률, 워크플로우 단계, 수정일 +- 소유자, 상태 배지 (활성/비활성/완료) +- 액션 버튼: + - [수정] - SYSTEM_ADMIN, ADMIN, USER (권한별) + - [삭제] - SYSTEM_ADMIN만 + +#### 사용자 테이블 +- 이메일, 이름, 직책, 부서, 역할, 상태 +- 가입일, 마지막 로그인 +- 액션 버튼: + - [수정] - 권한별 표시 + - [역할변경] - ADMIN (USER↔ADMIN만) + - [삭제] - SYSTEM_ADMIN, ADMIN (회사 내만) + +#### 검색/필터 +- **프로젝트:** 이름, 상태로 검색/필터 +- **사용자:** 이메일, 이름으로 검색, 역할/상태로 필터 + +--- + +## 4. UI 제어 로직 (권한 기반) + +### 4.1 버튼 표시 규칙 + +| 버튼 | 표시 조건 | +|------|---------| +| [프로젝트 수정] | `SYSTEM_ADMIN` \|\| (`ADMIN` && 회사 일치) \|\| (`USER` && 회사 일치) | +| [프로젝트 삭제] | `SYSTEM_ADMIN`만 | +| [사용자 추가] | `SYSTEM_ADMIN` \|\| `ADMIN` | +| [사용자 수정] | `SYSTEM_ADMIN` \|\| (`ADMIN` && 회사 일치) \|\| (`USER` && 자신) | +| [역할 변경] | (`ADMIN` && 회사 일치 && 대상 ≠ `SYSTEM_ADMIN`) | +| [사용자 삭제] | `SYSTEM_ADMIN` \|\| (`ADMIN` && 회사 일치 && 마지막 ADMIN 아님) | +| [자동화 로직 생성/수정] | `SYSTEM_ADMIN` \|\| `ADMIN` | +| [자동화 로직 실행] | `SYSTEM_ADMIN` \|\| `ADMIN` | +| [자동화 로직 삭제] | `SYSTEM_ADMIN` \|\| (`ADMIN` && 소유자) | + +### 4.2 입력 필드 비활성화 규칙 + +**프로젝트 수정 폼:** +- `SYSTEM_ADMIN`: 모든 필드 활성화 +- `ADMIN`: 모든 필드 활성화 +- `USER`: 진행 관련 필드만 활성화 (예: 좌표, 설정값, 진행도) + +**사용자 수정 폼:** +- `SYSTEM_ADMIN`: 모든 필드 활성화 +- `ADMIN`: 모든 필드 활성화 +- `USER` (자신): 이름, 직책, 부서, 연락처만 활성화 + +--- + +## 5. 필요한 API (백엔드) + +### 5.1 프로젝트 API + +| 메서드 | 엔드포인트 | 권한 검증 | +|--------|----------|----------| +| `PUT` | `/api/projects/{id}` | `SYSTEM_ADMIN` \|\| (`ADMIN` && 회사) \|\| (`USER` && 회사) | +| `DELETE` | `/api/projects/{id}` | `SYSTEM_ADMIN`만 | + +### 5.2 사용자 API + +| 메서드 | 엔드포인트 | 권한 검증 | +|--------|----------|----------| +| `POST` | `/api/companies/{cid}/users` | 가입 계정을 회사에 추가 (`SYSTEM_ADMIN` \|\| `ADMIN`) | +| `PUT` | `/api/users/{id}` | `SYSTEM_ADMIN` \|\| (`ADMIN` && 회사) \|\| (`USER` && 자신) | +| `PATCH` | `/api/users/{id}/role` | `ADMIN`: `USER`↔`ADMIN`만, `SYSTEM_ADMIN` 변경 차단 | +| `DELETE` | `/api/companies/{cid}/users/{uid}` | `SYSTEM_ADMIN` \|\| (`ADMIN` && 회사 && 마지막 ADMIN 아님) | +| `GET` | `/api/companies/{cid}/users` | `SYSTEM_ADMIN` \|\| (`ADMIN` && 회사) \|\| (`USER` && 회사) | +| `GET` | `/api/users?role=unused` | 가입했으나 회사 미배정 사용자 목록 (`ADMIN` 참조용) | + +### 5.3 자동화 설계 로직 API + +| 메서드 | 엔드포인트 | 권한 검증 | +|--------|----------|----------| +| `GET` | `/api/projects/{pid}/automations` | `SYSTEM_ADMIN` \|\| (`ADMIN` && 회사) \|\| (`USER` && 회사) | +| `POST` | `/api/projects/{pid}/automations` | `SYSTEM_ADMIN` \|\| (`ADMIN` && 회사) | +| `PUT` | `/api/automations/{aid}` | `SYSTEM_ADMIN` \|\| (`ADMIN` && 소유 회사) | +| `DELETE` | `/api/automations/{aid}` | `SYSTEM_ADMIN` \|\| (`ADMIN` && 소유 회사) | +| `POST` | `/api/automations/{aid}/execute` | `SYSTEM_ADMIN` \|\| (`ADMIN` && 소유 회사) | + +--- + +## 6. 구현 순서 (우선순위) + +| 우선순위 | 항목 | 복잡도 | 중요도 | 설명 | +|:---:|------|:---:|:---:|------| +| 1 | 프로젝트 수정 (`USER` 포함) | 중간 | 높음 | 프로젝트 진행 필수 | +| 1 | 사용자 추가 (가입 계정 기반) | 높음 | 높음 | 회사 관리 필수 | +| 1 | 역할 변경 (`ADMIN`: `USER`↔`ADMIN`, `SYSTEM_ADMIN` 차단) | 높음 | 높음 | 권한 제어 핵심 | +| 2 | 사용자 삭제 | 중간 | 높음 | 기본 CRUD | +| 2 | 프로젝트 삭제 (`SYSTEM_ADMIN`만) | 중간 | 중간 | 시스템 관리 기능 | +| 2 | 자동화 설계 로직 제어 (조회/생성/삭제) | 높음 | 높음 | ADMIN 핵심 기능 | +| 3 | 검색/필터 | 낮음 | 중간 | 사용성 개선 | +| 3 | 정렬 기능 | 낮음 | 낮음 | 사용성 개선 | + +--- + +## 7. 보안 및 제약사항 + +### 7.1 백엔드 권한 검증 필수 +- 모든 수정/삭제/역할변경 API에서 백엔드 권한 재검증 필수 +- 클라이언트 UI는 참고만 하고, 서버에서 최종 판단 +- `ADMIN`이 `SYSTEM_ADMIN`으로 변경 시도 시 차단 + +### 7.2 감시 로깅 +- 프로젝트 삭제: `audit_logs` 기록 +- 사용자 역할 변경: `audit_logs` 기록 +- 사용자 삭제: `audit_logs` 기록 +- 자동화 로직 삭제: `audit_logs` 기록 + +### 7.3 보호 조건 +- **마지막 ADMIN 삭제 방지:** 회사당 최소 1명 ADMIN 유지 +- **SYSTEM_ADMIN 역할 변경:** DB 직접 수정만 허용 (UI 제공 안함) +- **ADMIN의 자동화 로직 접근:** 자신 회사의 프로젝트에만 제한 +- **회사 간 데이터 격리:** ADMIN이 타사 데이터 접근 불가 + +--- + +## 8. 다국어 문구 추가 (ui_template_locale.ts) + +다음 키를 `ui_template_locale.ts`에 추가: + +```typescript +// 프로젝트 관리 +B01_Dashboard_EditProject: ["프로젝트 수정", "Edit Project"], +B01_Dashboard_DeleteProject: ["프로젝트 삭제", "Delete Project"], + +// 사용자 관리 +B01_Dashboard_AddUser: ["사용자 추가", "Add User"], +B01_Dashboard_EditUser: ["사용자 수정", "Edit User"], +B01_Dashboard_DeleteUser: ["사용자 삭제", "Delete User"], +B01_Dashboard_ChangeRole: ["역할 변경", "Change Role"], +B01_Dashboard_SelectAvailableUsers: ["사용 가능한 사용자 선택", "Select Available Users"], + +// 자동화 로직 +B01_Dashboard_AutomationLogic: ["자동화 설계 로직", "Automation Logic"], +B01_Dashboard_CreateAutomation: ["자동화 생성", "Create Automation"], +B01_Dashboard_EditAutomation: ["자동화 수정", "Edit Automation"], +B01_Dashboard_DeleteAutomation: ["자동화 삭제", "Delete Automation"], +B01_Dashboard_ExecuteAutomation: ["자동화 실행", "Execute Automation"], + +// 확인 메시지 +B01_Dashboard_Confirm_DeleteProject: ["프로젝트를 삭제하시겠습니까? 되돌릴 수 없습니다.", "Delete this project? This cannot be undone."], +B01_Dashboard_Confirm_DeleteUser: ["사용자를 삭제하시겠습니까?", "Delete this user?"], +B01_Dashboard_Confirm_LastAdmin: ["회사의 유일한 관리자는 삭제할 수 없습니다.", "Cannot delete the last admin of the company."], +B01_Dashboard_CannotChangeToSystemAdmin: ["시스템 관리자로 변경할 수 없습니다.", "Cannot change role to System Admin."], +``` + +--- + +## 9. 구현 체크리스트 + +### UI 컴포넌트 +- [ ] 프로젝트 수정 모달 (`openEditProjectModal`) +- [ ] 프로젝트 삭제 확인 모달 (`openDeleteProjectModal`) +- [ ] 사용자 추가 모달 (`openAddUserModal` - 가입 계정 목록) +- [ ] 사용자 수정 모달 (`openEditUserModal`) +- [ ] 사용자 역할 변경 모달 (`openChangeRoleModal`) +- [ ] 사용자 삭제 확인 모달 (`openDeleteUserModal`) +- [ ] 자동화 로직 생성/수정 모달 (`openAutomationModal`) +- [ ] 자동화 로직 삭제 확인 모달 (`openDeleteAutomationModal`) + +### 권한 검증 헬퍼 +- [ ] `canEditProject(user, project)` - 프로젝트 수정 권한 확인 +- [ ] `canDeleteProject(user)` - 프로젝트 삭제 권한 확인 (SYSTEM_ADMIN만) +- [ ] `canAddUser(user)` - 사용자 추가 권한 확인 +- [ ] `canChangeRole(user, targetUser)` - 역할 변경 권한 확인 +- [ ] `canDeleteUser(user, targetUser)` - 사용자 삭제 권한 확인 +- [ ] `canManageAutomation(user, automation)` - 자동화 로직 제어 권한 확인 +- [ ] `isLastAdmin(user, company)` - 회사의 마지막 ADMIN 확인 + +### API 구현 (백엔드) +- [ ] 프로젝트 수정 API - 권한 검증 +- [ ] 프로젝트 삭제 API - SYSTEM_ADMIN만 허용 +- [ ] 사용자 추가 API - 가입 계정 기반 +- [ ] 사용자 정보 수정 API - 권한별 필드 제한 +- [ ] 사용자 역할 변경 API - SYSTEM_ADMIN 변경 차단 +- [ ] 사용자 삭제 API - 마지막 ADMIN 보호 +- [ ] 자동화 로직 조회/생성/수정/삭제 API - 권한 검증 + +### 테스트 시나리오 +- [ ] SYSTEM_ADMIN: 모든 기능 가능 확인 +- [ ] ADMIN: 회사 내 기능만 가능 확인 +- [ ] ADMIN: SYSTEM_ADMIN으로 변경 시도 → 차단 확인 +- [ ] USER: 프로젝트 수정만 가능 확인 +- [ ] USER: 사용자 관리 불가 확인 +- [ ] 마지막 ADMIN 삭제 시도 → 차단 확인 + +--- + +## 10. 비고 + +- **코드 작업:** 다른 AI 담당 +- **계획서 위치:** `.agent/plan_dashboard_management.md` +- **참고 자료:** + - `.agent/structure.md` - 폴더 구조 + - `.agent/frontend.md` - UI 가이드라인 + - `.agent/backend.md` - 백엔드 명세 diff --git a/.agent/validation_dashboard_management.md b/.agent/validation_dashboard_management.md new file mode 100644 index 0000000..5d157ee --- /dev/null +++ b/.agent/validation_dashboard_management.md @@ -0,0 +1,60 @@ +# 대시보드 관리 기능 검증 보고서 (Validation Report) + +본 문서는 `.agent/plan_dashboard_management.md` 계획서를 기준으로 `B01_Dashboard` 관련 프론트엔드 및 백엔드 코드를 검증한 결과를 담고 있습니다. + +## 1. 종합 평가 +- **백엔드 (FastAPI/Python):** 계획서상 정의된 핵심 비즈니스 로직(역할 변경 및 제약조건, 마지막 ADMIN 보호 등)은 일정 부분 반영되었으나, **자동화 로직의 USER 조회 권한 누수** 등 계획서 명세와 다른 부분이 발견되었습니다. +- **프론트엔드 (TypeScript):** 프로젝트 수정/삭제, 사용자 수정/삭제/역할변경, 자동화 설계 로직 모달 등 계획서 9번에 정의된 **주요 UI 컴포넌트(모달) 및 권한 검증 헬퍼가 대부분 미구현(누락)** 상태입니다. +- **다국어 (i18n):** 계획서 8번에서 명시한 다국어 문구 키들이 `ui_template_locale.ts`에 전혀 반영되지 않았습니다. + +--- + +## 2. 세부 검증 항목 및 대조 결과 + +### 2.1 UI 컴포넌트 및 제어 (프론트엔드) + +| 요구 기능 (계획서 9. UI 컴포넌트) | 구현 상태 | 확인 코드 및 위치 | 특이사항 / 개선 필요 사항 | +| :--- | :---: | :--- | :--- | +| 프로젝트 수정 모달 (`openEditProjectModal`) | **❌ 미구현** | `B01_Dashboard_UI_Page.ts` | 프로젝트 목록에 수정 버튼 및 모달이 존재하지 않음 | +| 프로젝트 삭제 확인 모달 (`openDeleteProjectModal`) | **❌ 미구현** | `B01_Dashboard_UI_Page.ts` | 프로젝트 삭제 버튼 및 경고 모달 없음 | +| 사용자 추가 모달 (`openAddUserModal`) | **⚠️ 부분구현** | [openAddMemberModal](file:///D:/02_Software_Prog/%EC%9E%84%EB%8F%84%EC%84%A4%EA%B3%84%20%EB%B0%8F%20%EA%B2%AC%EC%A0%81%EC%9E%90%EB%8F%99%ED%99%94%20%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%A8%20%EA%B0%9C%EB%B0%9C/B01_Dashboard/B01_Dashboard_UI_Page.ts#L753) | 가입 계정 목록 조회 형식이 아니라 단순 이메일 입력 방식임 | +| 사용자 수정 모달 (`openEditUserModal`) | **❌ 미구현** | `B01_Dashboard_UI_Page.ts` | 사용자 수정 액션 및 모달 없음 | +| 사용자 역할 변경 모달 (`openChangeRoleModal`) | **❌ 미구현** | `B01_Dashboard_UI_Page.ts` | 역할 변경 모달 없음 | +| 사용자 삭제 확인 모달 (`openDeleteUserModal`) | **❌ 미구현** | `B01_Dashboard_UI_Page.ts` | 삭제 버튼은 있으나 단순 API 호출 처리 (경고 모달 없음) | +| 자동화 로직 생성/수정 모달 (`openAutomationModal`) | **❌ 미구현** | `B01_Dashboard_UI_Page.ts` | 대시보드 UI에 자동화 설계 로직 섹션이 완전 누락됨 | +| 자동화 로직 삭제 확인 모달 (`openDeleteAutomationModal`) | **❌ 미구현** | `B01_Dashboard_UI_Page.ts` | 위와 동일 | + +### 2.2 권한 검증 헬퍼 (프론트엔드) + +| 요구 헬퍼 함수 (계획서 9. 권한 검증 헬퍼) | 구현 상태 | 개선 필요 사항 | +| :--- | :---: | :--- | +| `canEditProject(user, project)` | **❌ 미구현** | UI 단의 권한 체크 분기 부재 | +| `canDeleteProject(user)` | **❌ 미구현** | SYSTEM_ADMIN 분기 처리 미흡 | +| `canAddUser(user)` | **❌ 미구현** | - | +| `canChangeRole(user, targetUser)` | **❌ 미구현** | - | +| `canDeleteUser(user, targetUser)` | **❌ 미구현** | - | +| `canManageAutomation(user, automation)` | **❌ 미구현** | - | +| `isLastAdmin(user, company)` | **❌ 미구현** | 프론트엔드 예외 처리 없음 (백엔드만 동작) | + +### 2.3 API 및 백엔드 권한 검증 (백엔드) + +| API 엔드포인트 / 기능 | 계획서 스펙 | 실제 구현 위치 | 검증 결과 | +| :--- | :--- | :--- | :--- | +| 프로젝트 수정 API | `/api/projects/{id}` | [dashboard_update_project](file:///D:/02_Software_Prog/%EC%9E%84%EB%8F%84%EC%84%A4%EA%B3%84%20%EB%B0%8F%20%EA%B2%AC%EC%A0%81%EC%9E%90%EB%8F%99%ED%99%94%20%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%A8%20%EA%B0%9C%EB%B0%9C/B01_Dashboard/B01_Dashboard_Router.py#L211) | **통과 (경로명 상이)**: 실 주소는 `/api/dashboard/projects/{project_id}` 임 | +| 프로젝트 삭제 API | `/api/projects/{id}` (SYSTEM_ADMIN만) | [dashboard_delete_project](file:///D:/02_Software_Prog/%EC%9E%84%EB%8F%84%EC%84%A4%EA%B3%84%20%EB%B0%8F%20%EA%B2%AC%EC%A0%81%EC%9E%90%EB%8F%99%ED%99%94%20%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%A8%20%EA%B0%9C%EB%B0%9C/B01_Dashboard/B01_Dashboard_Router.py#L227) | **통과 (경로명 상이)**: `require_system_admin` 데코레이터 적용 완료 | +| 사용자 추가 API | `/api/companies/{cid}/users` | [admin_add_member](file:///D:/02_Software_Prog/%EC%9E%84%EB%8F%84%EC%84%A4%EA%B3%84%20%EB%B0%8F%20%EA%B2%AC%EC%A0%81%EC%9E%90%EB%8F%99%ED%99%94%20%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%A8%20%EA%B0%9C%EB%B0%9C/B01_Dashboard/B01_Dashboard_Router.py#L160) | **통과**: `require_company_admin`으로 가입 계정 추가 제어 | +| 사용자 정보 수정 API | `/api/users/{id}` (필드 제한) | [admin_update_user](file:///D:/02_Software_Prog/%EC%9E%84%EB%8F%84%EC%84%A4%EA%B3%84%20%EB%B0%8F%20%EA%B2%AC%EC%A0%81%EC%9E%90%EB%8F%99%ED%99%94%20%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%A8%20%EA%B0%9C%EB%B0%9C/B01_Dashboard/B01_Dashboard_Router.py#L278) | **부분 통과**: USER일 때 status를 강제 비우는 세이프가드는 있으나, 다른 관리자 필드 차단 확인 필요 | +| 사용자 역할 변경 API | `/api/users/{id}/role` | [system_change_role](file:///D:/02_Software_Prog/%EC%9E%84%EB%8F%84%EC%84%A4%EA%B3%84%20%EB%B0%8F%20%EA%B2%AC%EC%A0%81%EC%9E%90%EB%8F%99%ED%99%94%20%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%A8%20%EA%B0%9C%EB%B0%9C/B01_Dashboard/B01_Dashboard_Router.py#L249) | **통과**: SYSTEM_ADMIN 변경 방지 및 마지막 ADMIN 변경 불가 보장 | +| 사용자 삭제 API | `/api/companies/{cid}/users/{uid}` | [admin_remove_member](file:///D:/02_Software_Prog/%EC%9E%84%EB%8F%84%EC%84%A4%EA%B3%84%20%EB%B0%8F%20%EA%B2%AC%EC%A0%81%EC%9E%90%EB%8F%99%ED%99%94%20%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%A8%20%EA%B0%9C%EB%B0%9C/B01_Dashboard/B01_Dashboard_Router.py#L171) | **통과**: 맴버 삭제 처리 로직 작동 | +| 자동화 로직 조회 API | `/api/projects/{pid}/automations` | [dashboard_project_automations](file:///D:/02_Software_Prog/%EC%9E%84%EB%8F%84%EC%84%A4%EA%B3%84%20%EB%B0%8F%20%EA%B2%AC%EC%A0%81%EC%9E%90%EB%8F%99%ED%99%94%20%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%A8%20%EA%B0%9C%EB%B0%9C/B01_Dashboard/B01_Dashboard_Router.py#L350) | **❌ 위반**: 일반 USER 권한도 조회 가능하게 설계되어 계획서의 USER 조회 불가(매트릭스 2.4) 위반 | + +--- + +## 3. 핵심 수정 필요 사항 + +1. **다국어 키 반영:** + `ui_template_locale.ts` 파일 하단에 계획서 8번의 다국어 문구 객체를 온전히 삽입해야 합니다. +2. **백엔드 권한 통제 강화:** + `B01_Dashboard_Router.py`의 `dashboard_project_automations` 등에서 `USER` 권한을 가진 사용자의 자동화 로직 조회를 차단해야 합니다. +3. **프론트엔드 UI/UX 완성:** + 계획서에 제시된 수정/삭제 모달 창을 `B01_Dashboard_UI_Page.ts`에 추가 구현하고 관련 버튼들을 조건부 활성화 및 노출 처리해야 합니다. diff --git a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts index e09326a..b64b219 100644 --- a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts +++ b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts @@ -18,6 +18,10 @@ export interface ProjectItem { id: string; name: string; region?: string | null; + road_type?: string | null; + project_year?: number | null; + estimated_length_m?: number | null; + memo?: string | null; status?: string | null; owner_name?: string | null; workflow_stage: number; @@ -92,6 +96,18 @@ export interface ResourceData { stats: ResourceSnapshot; } +export interface AutomationItem { + id: number; + project_id: string; + name: string; + logic_type: string; + config_json: Record; + status: "DRAFT" | "ACTIVE" | "INACTIVE"; + last_executed_at?: string | null; + created_at?: string | null; + updated_at?: string | null; +} + export interface UpdateUserRequest { name: string; position?: string | null; @@ -99,6 +115,27 @@ export interface UpdateUserRequest { phone?: string | null; } +export interface UpdateProjectRequest { + name: string; + region?: string | null; + road_type?: string | null; + project_year?: number | null; + estimated_length_m?: number | null; + memo?: string | null; + status?: string | null; +} + +export interface AdminUpdateUserRequest extends UpdateUserRequest { + status?: string | null; +} + +export interface AutomationRequest { + name: string; + logic_type: string; + config_json: Record; + status: "DRAFT" | "ACTIVE" | "INACTIVE"; +} + export interface CreateCompanyRequest { name: string; business_registration_number: string; @@ -212,7 +249,6 @@ export async function fetchAllProjects(): Promise { return data.projects; } - export async function fetchAllCompanies(): Promise { const data = await request<{ companies: CompanyInfo[] }>("/dashboard/admin/companies"); return data.companies; @@ -230,6 +266,27 @@ export function changeUserRole(userId: number, role: string): Promise { }); } +export function updateProject(projectId: string, payload: UpdateProjectRequest): Promise { + return request(`/dashboard/projects/${encodeURIComponent(projectId)}`, { + method: "PUT", + body: body(payload), + }); +} + +export function deleteProject(projectId: string): Promise { + return request(`/dashboard/projects/${encodeURIComponent(projectId)}`, { method: "DELETE" }); +} + +export function updateDashboardUser( + userId: number, + payload: AdminUpdateUserRequest, +): Promise { + return request(`/dashboard/admin/users/${userId}`, { + method: "PUT", + body: body(payload), + }); +} + export function assignUserToCompany(userId: number, companyId: number | null): Promise { return request(`/dashboard/admin/users/${userId}/company`, { method: "PATCH", @@ -254,3 +311,35 @@ export async function fetchAuditLogs(): Promise { export async function fetchSystemResources(days = 30): Promise { return request(`/dashboard/admin/resources?days=${encodeURIComponent(days)}`); } + +export async function fetchProjectAutomations(projectId: string): Promise { + const data = await request<{ automations: AutomationItem[] }>( + `/dashboard/projects/${encodeURIComponent(projectId)}/automations`, + ); + return data.automations; +} + +export function createProjectAutomation( + projectId: string, + payload: AutomationRequest, +): Promise { + return request(`/dashboard/projects/${encodeURIComponent(projectId)}/automations`, { + method: "POST", + body: body(payload), + }); +} + +export function updateProjectAutomation( + automationId: number, + payload: AutomationRequest, +): Promise { + return request(`/dashboard/automations/${automationId}`, { method: "PUT", body: body(payload) }); +} + +export function deleteProjectAutomation(automationId: number): Promise { + return request(`/dashboard/automations/${automationId}`, { method: "DELETE" }); +} + +export function executeProjectAutomation(automationId: number): Promise { + return request(`/dashboard/automations/${automationId}/execute`, { method: "POST" }); +} diff --git a/B01_Dashboard/B01_Dashboard_Repository.py b/B01_Dashboard/B01_Dashboard_Repository.py index f7e3b51..dc74bf2 100644 --- a/B01_Dashboard/B01_Dashboard_Repository.py +++ b/B01_Dashboard/B01_Dashboard_Repository.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import shutil from datetime import datetime, timedelta from typing import Any @@ -116,6 +117,69 @@ async def list_all_projects() -> list[dict[str, Any]]: return [_project_row(row) for row in await cursor.fetchall()] +async def get_project(project_id: str) -> dict[str, Any] | None: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """SELECT id, user_id, company_id, name, region, road_type, project_year, + estimated_length_m, memo, status + FROM projects WHERE id = %s AND deleted_at IS NULL""", + (project_id,), + ) + return await cursor.fetchone() + + +async def update_project(project_id: str, data: dict[str, Any], actor_id: int) -> bool: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await connection.begin() + await cursor.execute( + """UPDATE projects + SET name = %s, region = %s, road_type = %s, project_year = %s, + estimated_length_m = %s, memo = %s, status = COALESCE(%s, status) + WHERE id = %s AND deleted_at IS NULL""", + ( + data["name"], + data.get("region"), + data.get("road_type"), + data.get("project_year"), + data.get("estimated_length_m"), + data.get("memo"), + data.get("status"), + project_id, + ), + ) + changed = cursor.rowcount > 0 + if changed: + await cursor.execute( + """INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) + VALUES (%s, 'PROJECT_UPDATE', 'project', NULL)""", + (actor_id,), + ) + await connection.commit() + return changed + + +async def soft_delete_project(project_id: str, actor_id: int) -> bool: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await connection.begin() + await cursor.execute( + """UPDATE projects SET deleted_at = CURRENT_TIMESTAMP + WHERE id = %s AND deleted_at IS NULL""", + (project_id,), + ) + changed = cursor.rowcount > 0 + if changed: + await cursor.execute( + """INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) + VALUES (%s, 'PROJECT_DELETE', 'project', NULL)""", + (actor_id,), + ) + await connection.commit() + return changed + + async def get_user_company(company_id: int | None) -> dict[str, Any] | None: if company_id is None: return None @@ -358,6 +422,55 @@ async def change_user_role(user_id: int, role: str) -> bool: return changed +async def get_user_admin_target(user_id: int) -> dict[str, Any] | None: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """SELECT id, company_id, role, is_master, status + FROM users WHERE id = %s AND deleted_at IS NULL""", + (user_id,), + ) + row = await cursor.fetchone() + if row: + row["role"] = _role(row.get("role")) + row["is_master"] = bool(row.get("is_master")) + return row + + +async def update_admin_user(user_id: int, data: dict[str, Any]) -> bool: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute( + """UPDATE users + SET name = %s, position = %s, department = %s, phone = %s, + status = COALESCE(%s, status) + WHERE id = %s AND deleted_at IS NULL""", + ( + data["name"], + data.get("position"), + data.get("department"), + data.get("phone"), + data.get("status"), + user_id, + ), + ) + changed = cursor.rowcount > 0 + await connection.commit() + return changed + + +async def count_company_admins(company_id: int) -> int: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """SELECT COUNT(*) AS cnt FROM users + WHERE company_id = %s AND role = 'ADMIN' AND deleted_at IS NULL""", + (company_id,), + ) + row = await cursor.fetchone() + return int(row["cnt"] if row else 0) + + async def assign_user_company(user_id: int, company_id: int | None) -> bool: status = "ACTIVE" if company_id else "NO_COMPANY" pool = get_db_pool() @@ -442,3 +555,114 @@ async def get_system_resources(days: int = 30) -> dict[str, Any]: (bucket_seconds, bucket_seconds, since, bucket_seconds), ) return {"current": current, "history": list(await cursor.fetchall()), "stats": current} + + +async def list_project_automations(project_id: str) -> list[dict[str, Any]]: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """SELECT id, project_id, name, logic_type, config_json, status, + last_executed_at, created_at, updated_at + FROM project_automations + WHERE project_id = %s AND deleted_at IS NULL + ORDER BY updated_at DESC, created_at DESC""", + (project_id,), + ) + return list(await cursor.fetchall()) + + +async def create_project_automation( + project_id: str, actor_id: int, data: dict[str, Any] +) -> dict[str, Any]: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await connection.begin() + await cursor.execute( + """INSERT INTO project_automations + (project_id, name, logic_type, config_json, status, created_by, updated_by) + VALUES (%s, %s, %s, %s, %s, %s, %s)""", + ( + project_id, + data["name"], + data["logic_type"], + json.dumps(data["config_json"], ensure_ascii=False), + data["status"], + actor_id, + actor_id, + ), + ) + automation_id = cursor.lastrowid + await connection.commit() + await cursor.execute("SELECT * FROM project_automations WHERE id = %s", (automation_id,)) + return await cursor.fetchone() + + +async def get_project_automation(automation_id: int) -> dict[str, Any] | None: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """SELECT a.*, p.company_id + FROM project_automations a + JOIN projects p ON p.id = a.project_id + WHERE a.id = %s AND a.deleted_at IS NULL AND p.deleted_at IS NULL""", + (automation_id,), + ) + return await cursor.fetchone() + + +async def update_project_automation( + automation_id: int, actor_id: int, data: dict[str, Any] +) -> bool: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute( + """UPDATE project_automations + SET name = %s, logic_type = %s, config_json = %s, status = %s, updated_by = %s + WHERE id = %s AND deleted_at IS NULL""", + ( + data["name"], + data["logic_type"], + json.dumps(data["config_json"], ensure_ascii=False), + data["status"], + actor_id, + automation_id, + ), + ) + changed = cursor.rowcount > 0 + await connection.commit() + return changed + + +async def delete_project_automation(automation_id: int, actor_id: int) -> bool: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await connection.begin() + await cursor.execute( + """UPDATE project_automations + SET deleted_at = CURRENT_TIMESTAMP, updated_by = %s + WHERE id = %s AND deleted_at IS NULL""", + (actor_id, automation_id), + ) + changed = cursor.rowcount > 0 + if changed: + await cursor.execute( + """INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) + VALUES (%s, 'AUTOMATION_DELETE', 'automation', %s)""", + (actor_id, automation_id), + ) + await connection.commit() + return changed + + +async def mark_project_automation_executed(automation_id: int, actor_id: int) -> bool: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute( + """UPDATE project_automations + SET last_executed_at = CURRENT_TIMESTAMP, updated_by = %s + WHERE id = %s AND deleted_at IS NULL""", + (actor_id, automation_id), + ) + changed = cursor.rowcount > 0 + await connection.commit() + return changed diff --git a/B01_Dashboard/B01_Dashboard_Router.py b/B01_Dashboard/B01_Dashboard_Router.py index 1fd3823..ccd74f4 100644 --- a/B01_Dashboard/B01_Dashboard_Router.py +++ b/B01_Dashboard/B01_Dashboard_Router.py @@ -10,9 +10,15 @@ from .B01_Dashboard_Repository import ( add_company_member, assign_user_company, change_user_role, + count_company_admins, create_company, + create_project_automation, + delete_project_automation, get_dashboard_me, + get_project, + get_project_automation, get_system_resources, + get_user_admin_target, get_user_company, join_company, list_all_companies, @@ -22,19 +28,28 @@ from .B01_Dashboard_Repository import ( list_company_members, list_company_projects, list_join_requests, + list_project_automations, list_user_projects, + mark_project_automation_executed, process_join_request, remove_company_member, search_companies, + soft_delete_project, + update_admin_user, + update_project, + update_project_automation, update_user_profile, ) from .B01_Dashboard_Schema import ( AddMemberRequest, + AdminUpdateUserRequest, AssignCompanyRequest, + AutomationRequest, ChangeUserRoleRequest, CreateCompanyRequest, JoinCompanyRequest, ProcessJoinRequest, + UpdateProjectRequest, UpdateUserRequest, ) @@ -56,6 +71,30 @@ def _require_company_id(session: dict[str, Any]) -> int: return int(company_id) +def _same_company(session: dict[str, Any], company_id: int | None) -> bool: + return company_id is not None and int(session.get("company_id") or 0) == int(company_id) + + +def _can_edit_project(session: dict[str, Any], project: dict[str, Any]) -> bool: + if session["role"] == "SYSTEM_ADMIN": + return True + return _same_company(session, project.get("company_id")) + + +def _can_manage_automation(session: dict[str, Any], project: dict[str, Any]) -> bool: + if session["role"] == "SYSTEM_ADMIN": + return True + return session["role"] == "ADMIN" and _same_company(session, project.get("company_id")) + + +def _can_edit_user(session: dict[str, Any], target: dict[str, Any]) -> bool: + if session["role"] == "SYSTEM_ADMIN": + return True + if session["role"] == "ADMIN": + return _same_company(session, target.get("company_id")) + return int(session["user_id"]) == int(target["id"]) + + @router.get("/me") async def dashboard_me(session: dict[str, Any] = Depends(verify_session)): user = await get_dashboard_me(int(session["user_id"])) @@ -149,11 +188,12 @@ async def admin_process_join_request( payload: ProcessJoinRequest, session: dict[str, Any] = Depends(require_company_admin), ): + company_id = None if session["role"] == "SYSTEM_ADMIN" else _require_company_id(session) changed = await process_join_request( request_id, int(session["user_id"]), payload.action == "APPROVE", - _require_company_id(session), + company_id, ) if not changed: raise HTTPException(status_code=404, detail="처리할 가입 신청을 찾을 수 없습니다.") @@ -168,6 +208,32 @@ async def admin_projects(session: dict[str, Any] = Depends(require_company_admin } +@router.put("/projects/{project_id}") +async def dashboard_update_project( + project_id: str, + payload: UpdateProjectRequest, + session: dict[str, Any] = Depends(verify_session), +): + project = await get_project(project_id) + if not project: + raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") + if not _can_edit_project(session, project): + raise HTTPException(status_code=403, detail="프로젝트 수정 권한이 없습니다.") + if not await update_project(project_id, payload.model_dump(), int(session["user_id"])): + raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") + return {"status": "success"} + + +@router.delete("/projects/{project_id}") +async def dashboard_delete_project( + project_id: str, + session: dict[str, Any] = Depends(require_system_admin), +): + if not await soft_delete_project(project_id, int(session["user_id"])): + raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") + return {"status": "success"} + + @router.get("/admin/companies") async def system_companies(session: dict[str, Any] = Depends(require_system_admin)): _ = session @@ -184,14 +250,50 @@ async def system_users(session: dict[str, Any] = Depends(require_system_admin)): async def system_change_role( user_id: int, payload: ChangeUserRoleRequest, - session: dict[str, Any] = Depends(require_system_admin), + session: dict[str, Any] = Depends(verify_session), ): - _ = session + target = await get_user_admin_target(user_id) + if not target: + raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.") + if payload.role == "SYSTEM_ADMIN" or target["role"] == "SYSTEM_ADMIN": + raise HTTPException( + status_code=403, detail="시스템 관리자 역할은 API에서 변경할 수 없습니다." + ) + if session["role"] == "ADMIN": + if not _same_company(session, target.get("company_id")): + raise HTTPException(status_code=403, detail="같은 회사 사용자만 변경할 수 있습니다.") + if target["role"] == "ADMIN" and payload.role == "USER": + admins = await count_company_admins(int(target["company_id"])) + if admins <= 1: + raise HTTPException( + status_code=409, detail="회사의 마지막 관리자는 변경할 수 없습니다." + ) + elif session["role"] != "SYSTEM_ADMIN": + raise HTTPException(status_code=403, detail="역할 변경 권한이 없습니다.") if not await change_user_role(user_id, payload.role): raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.") return {"status": "success"} +@router.put("/admin/users/{user_id}") +async def admin_update_user( + user_id: int, + payload: AdminUpdateUserRequest, + session: dict[str, Any] = Depends(verify_session), +): + target = await get_user_admin_target(user_id) + if not target: + raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.") + if not _can_edit_user(session, target): + raise HTTPException(status_code=403, detail="사용자 수정 권한이 없습니다.") + data = payload.model_dump() + if session["role"] == "USER": + data["status"] = None + if not await update_admin_user(user_id, data): + raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.") + return {"status": "success"} + + @router.patch("/admin/users/{user_id}/company") async def system_assign_company( user_id: int, @@ -244,3 +346,80 @@ async def system_projects(session: dict[str, Any] = Depends(require_system_admin _ = session return {"status": "success", "projects": await list_all_projects()} + +@router.get("/projects/{project_id}/automations") +async def dashboard_project_automations( + project_id: str, + session: dict[str, Any] = Depends(verify_session), +): + project = await get_project(project_id) + if not project: + raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") + if not _can_edit_project(session, project): + raise HTTPException(status_code=403, detail="자동화 로직 조회 권한이 없습니다.") + return {"status": "success", "automations": await list_project_automations(project_id)} + + +@router.post("/projects/{project_id}/automations") +async def dashboard_create_automation( + project_id: str, + payload: AutomationRequest, + session: dict[str, Any] = Depends(verify_session), +): + project = await get_project(project_id) + if not project: + raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") + if not _can_manage_automation(session, project): + raise HTTPException(status_code=403, detail="자동화 로직 생성 권한이 없습니다.") + automation = await create_project_automation( + project_id, int(session["user_id"]), payload.model_dump() + ) + return {"status": "success", "automation": automation} + + +@router.put("/automations/{automation_id}") +async def dashboard_update_automation( + automation_id: int, + payload: AutomationRequest, + session: dict[str, Any] = Depends(verify_session), +): + automation = await get_project_automation(automation_id) + if not automation: + raise HTTPException(status_code=404, detail="자동화 로직을 찾을 수 없습니다.") + if not _can_manage_automation(session, automation): + raise HTTPException(status_code=403, detail="자동화 로직 수정 권한이 없습니다.") + if not await update_project_automation( + automation_id, int(session["user_id"]), payload.model_dump() + ): + raise HTTPException(status_code=404, detail="자동화 로직을 찾을 수 없습니다.") + return {"status": "success"} + + +@router.delete("/automations/{automation_id}") +async def dashboard_delete_automation( + automation_id: int, + session: dict[str, Any] = Depends(verify_session), +): + automation = await get_project_automation(automation_id) + if not automation: + raise HTTPException(status_code=404, detail="자동화 로직을 찾을 수 없습니다.") + if not _can_manage_automation(session, automation): + raise HTTPException(status_code=403, detail="자동화 로직 삭제 권한이 없습니다.") + if not await delete_project_automation(automation_id, int(session["user_id"])): + raise HTTPException(status_code=404, detail="자동화 로직을 찾을 수 없습니다.") + return {"status": "success"} + + +@router.post("/automations/{automation_id}/execute") +async def dashboard_execute_automation( + automation_id: int, + session: dict[str, Any] = Depends(verify_session), +): + automation = await get_project_automation(automation_id) + if not automation: + raise HTTPException(status_code=404, detail="자동화 로직을 찾을 수 없습니다.") + if not _can_manage_automation(session, automation): + raise HTTPException(status_code=403, detail="자동화 로직 실행 권한이 없습니다.") + if not await mark_project_automation_executed(automation_id, int(session["user_id"])): + raise HTTPException(status_code=404, detail="자동화 로직을 찾을 수 없습니다.") + return {"status": "success"} diff --git a/B01_Dashboard/B01_Dashboard_Schema.py b/B01_Dashboard/B01_Dashboard_Schema.py index 4779fa7..ea317ce 100644 --- a/B01_Dashboard/B01_Dashboard_Schema.py +++ b/B01_Dashboard/B01_Dashboard_Schema.py @@ -36,3 +36,26 @@ class ChangeUserRoleRequest(BaseModel): class AssignCompanyRequest(BaseModel): company_id: int | None = Field(default=None, gt=0) + + +class UpdateProjectRequest(BaseModel): + name: str = Field(min_length=1, max_length=255) + region: str | None = Field(default=None, max_length=100) + road_type: str | None = Field(default=None, max_length=100) + project_year: int | None = Field(default=None, ge=1900, le=2100) + estimated_length_m: float | None = Field(default=None, ge=0) + memo: str | None = Field(default=None, max_length=5000) + status: str | None = Field(default=None, max_length=50) + + +class AdminUpdateUserRequest(UpdateUserRequest): + status: str | None = Field( + default=None, pattern="^(NO_COMPANY|PENDING|ACTIVE|INACTIVE|REJECTED)$" + ) + + +class AutomationRequest(BaseModel): + name: str = Field(min_length=1, max_length=100) + logic_type: str = Field(min_length=1, max_length=50) + config_json: dict = Field(default_factory=dict) + status: str = Field(default="DRAFT", pattern="^(DRAFT|ACTIVE|INACTIVE)$") diff --git a/CLAUDE.md b/CLAUDE.md index 72525e3..b2dd1e8 120000 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -157,8 +157,8 @@ WF6: B09_wf6_Estimation (견적·문서) ## 6. 주요 문서 참고 순서 1. **구조 설계:** `.agent/structure.md` 읽기 -2. **프론트 구현:** `.agent/frontend.md` 읽기 -3. **백엔드 구현:** `.agent/backend.md` 읽기 +2. **프론트 구현:** `.agent/frontend.md` 읽기 (대시보드 관리 UI: `.agent/plan_dashboard_management.md` 병행) +3. **백엔드 구현:** `.agent/backend.md` 읽기 (대시보드 관리 API: `.agent/plan_dashboard_management.md` 병행) 4. **마이그레이션:** `.agent/migration_plan.md` 읽기 5. **DB 스키마:** `.agent/db_schema_simple.md` 읽기 6. **프로젝트 정보:** `.agent/project_info.md` 읽기 diff --git a/db_management/004_dashboard.sql b/db_management/004_dashboard.sql index dbf2df1..c003113 100644 --- a/db_management/004_dashboard.sql +++ b/db_management/004_dashboard.sql @@ -50,6 +50,29 @@ CREATE TABLE IF NOT EXISTS system_resources ( INDEX idx_system_resources_timestamp (timestamp) ); +CREATE TABLE IF NOT EXISTS project_automations ( + id INT PRIMARY KEY AUTO_INCREMENT, + project_id CHAR(36) NOT NULL, + name VARCHAR(100) NOT NULL, + logic_type VARCHAR(50) NOT NULL, + config_json JSON NOT NULL, + status ENUM('DRAFT', 'ACTIVE', 'INACTIVE') NOT NULL DEFAULT 'DRAFT', + created_by INT NOT NULL, + updated_by INT NULL, + last_executed_at DATETIME NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL DEFAULT NULL, + INDEX idx_project_automations_project_id (project_id), + INDEX idx_project_automations_status (status), + CONSTRAINT fk_project_automations_project_id FOREIGN KEY (project_id) REFERENCES projects(id) + ON DELETE CASCADE, + CONSTRAINT fk_project_automations_created_by FOREIGN KEY (created_by) REFERENCES users(id) + ON DELETE RESTRICT, + CONSTRAINT fk_project_automations_updated_by FOREIGN KEY (updated_by) REFERENCES users(id) + ON DELETE SET NULL +); + ALTER TABLE join_requests ADD COLUMN IF NOT EXISTS review_comment TEXT NULL AFTER reviewed_at;