From 0b967a84fa882be917a7544c69f2d0ed01faf223 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Wed, 24 Sep 2025 09:12:39 +0900 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=80=20=EC=8B=9C=EB=86=80=EB=A1=9C?= =?UTF-8?q?=EC=A7=80=20=EB=B0=B0=ED=8F=AC=20=EC=A4=80=EB=B9=84=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✨ 주요 변경사항: - 단일 docker-compose.yml로 통합 (로컬/시놀로지 환경 지원) - 시놀로지 볼륨 매핑 설정 (volume1: 이미지, volume3: 데이터) - 통합 배포 가이드 및 자동 배포 스크립트 추가 - 완전한 Memos 스타일 워크플로우 구현 🎯 새로운 기능: - 📝 메모 작성 (upload.html) - 이미지 업로드 지원 - 📥 수신함 (inbox.html) - 메모 편집 및 Todo/보드 변환 - ✅ Todo 목록 (todo-list.html) - 오늘 할 일 관리 - 📋 보드 (board.html) - 프로젝트 관리, 접기/펼치기, 이미지 지원 - 📚 아카이브 (archive.html) - 완료된 보드 보관 - 🔐 초기 설정 화면 - 관리자 계정 생성 🔧 기술적 개선: - 이미지 업로드/편집 완전 지원 - 반응형 디자인 및 모바일 최적화 - 보드 완료 후 자동 숨김 처리 - 메모 편집 시 제목 필드 제거 - 테스트 로그인 버튼 제거 (프로덕션 준비) - 과거 코드 정리 (TodoService, CalendarSyncService 등) 📦 배포 관련: - env.synology.example - 시놀로지 환경 설정 템플릿 - SYNOLOGY_DEPLOYMENT_GUIDE.md - 상세한 배포 가이드 - deploy-synology.sh - 원클릭 자동 배포 스크립트 - Nginx 정적 파일 서빙 및 이미지 프록시 설정 🗑️ 정리된 파일: - 사용하지 않는 HTML 페이지들 (dashboard, calendar, checklist 등) - 복잡한 통합 서비스들 (integrations 폴더) - 중복된 시놀로지 설정 파일들 --- README.md | 192 +- SYNOLOGY_DEPLOYMENT_GUIDE.md | 326 +++ SYNOLOGY_INSTALL.md | 285 -- backend/Dockerfile | 1 + .../migrations/003_optimize_for_workflow.sql | 38 + backend/src/api/routes/calendar.py | 11 +- backend/src/api/routes/setup.py | 121 + backend/src/api/routes/todos.py | 88 +- backend/src/core/config.py | 4 +- backend/src/core/database.py | 30 +- backend/src/integrations/__init__.py | 0 backend/src/integrations/calendar/__init__.py | 32 - backend/src/integrations/calendar/apple.py | 370 --- backend/src/integrations/calendar/base.py | 332 --- backend/src/integrations/calendar/router.py | 363 --- backend/src/integrations/calendar/synology.py | 401 --- backend/src/main.py | 68 +- backend/src/models/todo.py | 19 +- backend/src/schemas/todo.py | 57 +- backend/src/services/__init__.py | 9 +- backend/src/services/calendar_sync_service.py | 74 - backend/src/services/file_service.py | 41 + backend/src/services/todo_service.py | 300 --- deploy-synology.sh | 179 ++ docker-compose.yml | 68 +- env.synology.example | 46 + frontend/Dockerfile | 4 +- frontend/archive.html | 574 ++++ frontend/board.html | 1141 ++++++++ frontend/calendar.html | 398 --- frontend/checklist.html | 604 ----- frontend/classify.html | 623 ----- frontend/dashboard.html | 2325 ----------------- frontend/inbox.html | 798 ++++++ frontend/index.html | 316 ++- frontend/nginx.conf | 60 + frontend/static/js/api.js | 5 +- frontend/static/js/auth.js | 4 +- frontend/static/js/todos.js | 2 +- frontend/todo-list.html | 643 +++++ frontend/todo.html | 353 --- frontend/upload.html | 853 ++++++ 42 files changed, 5467 insertions(+), 6691 deletions(-) create mode 100644 SYNOLOGY_DEPLOYMENT_GUIDE.md delete mode 100644 SYNOLOGY_INSTALL.md create mode 100644 backend/migrations/003_optimize_for_workflow.sql create mode 100644 backend/src/api/routes/setup.py delete mode 100644 backend/src/integrations/__init__.py delete mode 100644 backend/src/integrations/calendar/__init__.py delete mode 100644 backend/src/integrations/calendar/apple.py delete mode 100644 backend/src/integrations/calendar/base.py delete mode 100644 backend/src/integrations/calendar/router.py delete mode 100644 backend/src/integrations/calendar/synology.py delete mode 100644 backend/src/services/calendar_sync_service.py delete mode 100644 backend/src/services/todo_service.py create mode 100755 deploy-synology.sh create mode 100644 env.synology.example create mode 100644 frontend/archive.html create mode 100644 frontend/board.html delete mode 100644 frontend/calendar.html delete mode 100644 frontend/checklist.html delete mode 100644 frontend/classify.html delete mode 100644 frontend/dashboard.html create mode 100644 frontend/inbox.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/todo-list.html delete mode 100644 frontend/todo.html create mode 100644 frontend/upload.html diff --git a/README.md b/README.md index ed08e06..06dd221 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,193 @@ # 📱 Todo Project -간단하고 효율적인 할일 관리 시스템 +**메모 → 수신함 → Todo 관리**의 간단하고 직관적인 워크플로우 + +## 🎯 핵심 워크플로우 + +``` +📝 메모 작성 → 📥 수신함 확인 → ✅ Todo 변환 → 📋 Todo 관리 +``` + +### 3개 페이지로 완성되는 심플한 구조: +1. **📝 새 메모** (`/upload.html`) - 빠른 메모 작성 +2. **📥 수신함** (`/inbox.html`) - 메모 확인 & Todo 변환 +3. **📋 Todo 목록** (`/todo-list.html`) - 오늘 할 일 관리 ## ✨ 주요 기능 -- 📊 **반응형 대시보드**: 데스크톱/모바일 최적화 -- 📥 **스마트 분류**: AI 기반 자동 분류 제안 -- 📷 **이미지 업로드**: 사진과 함께 메모 관리 -- 🏷️ **3가지 분류**: Todo, 캘린더, 체크리스트 -- 📱 **PWA 지원**: 홈화면 추가 가능 -- 🔄 **시놀로지 연동**: 메일플러스 자동 연동 +- 🖊️ **빠른 메모 작성**: 텍스트 + 이미지 (최대 5장) +- 📅 **시작일 기반 Todo**: 해야 할 시점이 된 Todo만 표시 +- 🏷️ **스마트 상태 관리**: 오늘 시작 / 진행 중 자동 구분 +- 📱 **모바일 최적화**: 카메라/갤러리 업로드 지원 +- 🔄 **지연 관리**: +3일, +5일, 날짜 선택 연장 +- 🎨 **빈티지 UI**: 양피지 테마의 아름다운 인터페이스 ## 🚀 빠른 시작 +```bash +# 프로젝트 클론 +git clone https://git.hyungi.net/hyungi/Todo-Project.git +cd Todo-Project + +# Docker로 실행 +docker-compose up -d + +# 접속 +open http://localhost:4000 +``` + +**기본 계정**: `hyungi` / `admin` + +## 🏗️ 기술 스택 + +### Frontend +- **HTML5/CSS3/JavaScript**: 바닐라 JS로 가벼운 구현 +- **Tailwind CSS**: 빠른 스타일링 +- **PWA**: 홈화면 추가 지원 +- **Nginx**: 정적 파일 서빙 & API 프록시 + +### Backend +- **FastAPI**: 고성능 Python API +- **SQLAlchemy**: 비동기 ORM +- **PostgreSQL**: 안정적인 데이터베이스 +- **Pydantic**: 데이터 검증 + +### Infrastructure +- **Docker Compose**: 컨테이너 오케스트레이션 +- **Nginx**: 리버스 프록시 +- **Volume**: 데이터 영속성 + +## 📊 데이터베이스 구조 + +### 📋 todos 테이블 +```sql +Column | Type | 설명 +-------------|--------------------------|------------------ +id | uuid | 고유 ID +user_id | uuid | 사용자 ID +title | varchar(200) | 제목 (메모는 선택사항) +description | text | 내용 (필수) +category | enum | MEMO | TODO +status | enum | pending | completed +start_date | timestamp | Todo 시작일 +image_urls | text | 이미지 URLs (JSON) +created_at | timestamp | 생성일 +updated_at | timestamp | 수정일 +completed_at | timestamp | 완료일 +``` + +### 🚀 성능 최적화 인덱스 +- `idx_todos_workflow`: 복합 워크플로우 조회 최적화 +- `idx_todos_start_date`: 시작일 기준 조회 +- `idx_todos_category_status`: 카테고리별 상태 조회 + +## 🔄 API 엔드포인트 + +### 인증 +- `POST /api/auth/login` - 로그인 +- `GET /api/auth/me` - 사용자 정보 + +### Todo/메모 관리 +- `POST /api/todos` - 메모/Todo 생성 +- `GET /api/todos?category=memo` - 메모 목록 (수신함) +- `GET /api/todos?category=todo` - Todo 목록 +- `PUT /api/todos/{id}` - Todo 수정 (카테고리 변환, 상태 변경) +- `POST /api/todos/upload-image` - 이미지 업로드 + +## 📱 모바일 지원 + +### PWA 기능 +- 홈화면 추가 가능 +- 오프라인 지원 (예정) +- 푸시 알림 (예정) + +### 모바일 최적화 +- 터치 친화적 UI +- 카메라/갤러리 접근 +- 키보드 대응 스크롤 +- 이미지 자동 압축 + +## 🎨 UI/UX 특징 + +### 빈티지 양피지 테마 +- 따뜻한 세피아 색상 +- 손글씨 느낌의 폰트 +- 그림자와 테두리 효과 +- 직관적인 아이콘 + +### 반응형 디자인 +- 데스크톱: 넓은 레이아웃 +- 모바일: 세로 최적화 +- 터치 제스처 지원 + +## 🔧 개발 환경 설정 + +### 로컬 개발 +```bash +# 백엔드 개발 +cd backend +pip install -e . +uvicorn src.main:app --reload --port 9000 + +# 프론트엔드 개발 +cd frontend +python -m http.server 8000 +``` + +### 환경 변수 +```env +# 데이터베이스 +DATABASE_URL=postgresql+asyncpg://todo_user:todo_password@localhost:5432/todo_db + +# JWT 설정 +SECRET_KEY=your-secret-key +ACCESS_TOKEN_EXPIRE_MINUTES=30 + +# 파일 업로드 +UPLOAD_DIR=/data/uploads +MAX_FILE_SIZE=5242880 # 5MB +``` + +## 📈 성능 최적화 + +### 데이터베이스 +- 복합 인덱스로 쿼리 최적화 +- 카테고리별 분리로 효율적 조회 +- 이미지 URL JSON 저장으로 정규화 최소화 + +### 프론트엔드 +- 바닐라 JS로 번들 크기 최소화 +- 이미지 클라이언트 압축 +- 불필요한 로깅 제거 +- Nginx 정적 파일 캐싱 + +## 🚀 배포 + +### Docker Compose (권장) ```bash docker-compose up -d ``` -접속: http://localhost:4000 +### 시놀로지 NAS +자세한 설치 가이드: [SYNOLOGY_INSTALL.md](SYNOLOGY_INSTALL.md) -## 📋 요구사항 +## 📖 추가 문서 -- Docker & Docker Compose -- Python 3.11+ -- PostgreSQL 15+ +- [📚 종합 개발 가이드](COMPREHENSIVE_GUIDE.md) - 상세한 개발 가이드 +- [🏠 시놀로지 설치](SYNOLOGY_INSTALL.md) - NAS 설치 방법 -## 📖 자세한 가이드 +## 🤝 기여하기 -- [종합 개발 가이드](COMPREHENSIVE_GUIDE.md) -- [시놀로지 설치 가이드](SYNOLOGY_INSTALL.md) +1. Fork the Project +2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) +3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the Branch (`git push origin feature/AmazingFeature`) +5. Open a Pull Request + +## 📄 라이선스 + +이 프로젝트는 MIT 라이선스 하에 배포됩니다. + +## 📞 문의 + +프로젝트 링크: [https://git.hyungi.net/hyungi/Todo-Project](https://git.hyungi.net/hyungi/Todo-Project) diff --git a/SYNOLOGY_DEPLOYMENT_GUIDE.md b/SYNOLOGY_DEPLOYMENT_GUIDE.md new file mode 100644 index 0000000..baf9f59 --- /dev/null +++ b/SYNOLOGY_DEPLOYMENT_GUIDE.md @@ -0,0 +1,326 @@ +# 🚀 Todo-Project 시놀로지 배포 가이드 + +## 📋 목차 +1. [사전 준비](#사전-준비) +2. [시놀로지 환경 설정](#시놀로지-환경-설정) +3. [프로젝트 배포](#프로젝트-배포) +4. [환경 설정](#환경-설정) +5. [배포 실행](#배포-실행) +6. [접속 및 확인](#접속-및-확인) +7. [문제 해결](#문제-해결) +8. [유지보수](#유지보수) + +--- + +## 🛠️ 사전 준비 + +### 시놀로지 요구사항 +- **DSM 7.0 이상** +- **Docker 패키지 설치** +- **Container Manager 설치** (DSM 7.2+) 또는 **Docker 패키지** (DSM 7.1 이하) +- **최소 2GB RAM** (권장: 4GB 이상) +- **최소 5GB 저장공간** + +### 필요한 포트 +- **4000**: 프론트엔드 (웹 인터페이스) +- **9000**: 백엔드 API +- **5432**: PostgreSQL 데이터베이스 + +--- + +## 🏗️ 시놀로지 환경 설정 + +### 1. 디렉토리 구조 생성 + +SSH 또는 File Station을 통해 다음 디렉토리를 생성하세요: + +```bash +# 이미지 업로드 저장소 (volume1 - 빠른 액세스) +sudo mkdir -p /volume1/todo-project/uploads +sudo chmod 755 /volume1/todo-project/uploads + +# 설정 및 데이터베이스 저장소 (volume3) +sudo mkdir -p /volume3/docker/todo-project/config +sudo mkdir -p /volume3/docker/todo-project/postgres +sudo mkdir -p /volume3/docker/todo-project/app + +# 권한 설정 +sudo chown -R 1000:1000 /volume1/todo-project +sudo chown -R 999:999 /volume3/docker/todo-project/postgres +sudo chown -R 1000:1000 /volume3/docker/todo-project/config +``` + +### 2. 방화벽 설정 (선택사항) + +DSM > 제어판 > 보안 > 방화벽에서 다음 포트를 허용하세요: +- **4000/TCP** (Todo-Project 웹 인터페이스) +- **9000/TCP** (API 서버) + +--- + +## 📦 프로젝트 배포 + +### 방법 1: Git Clone (권장) + +```bash +# 시놀로지에 SSH 접속 후 +cd /volume3/docker/todo-project/app +git clone https://github.com/your-username/Todo-Project.git . + +# 또는 특정 브랜치 +git clone -b main https://github.com/your-username/Todo-Project.git . +``` + +### 방법 2: 파일 업로드 + +1. **로컬에서 프로젝트 압축**: + ```bash + tar -czf todo-project.tar.gz --exclude='.git' --exclude='node_modules' --exclude='__pycache__' . + ``` + +2. **시놀로지로 업로드**: + - File Station을 통해 `/volume3/docker/todo-project/app/`에 업로드 + - 압축 해제: `tar -xzf todo-project.tar.gz` + +--- + +## ⚙️ 환경 설정 + +### 1. 환경 파일 설정 + +```bash +cd /volume3/docker/todo-project/app +cp env.synology.example .env +``` + +### 2. 환경 변수 수정 + +`.env` 파일을 편집하여 시놀로지 환경에 맞게 설정: + +```bash +# 필수 설정 (반드시 변경!) +SECRET_KEY=your-very-long-and-random-secret-key-for-production +POSTGRES_PASSWORD=your-secure-database-password-123 + +# 포트 설정 (필요시 변경) +FRONTEND_PORT=4000 +BACKEND_PORT=9000 +DATABASE_PORT=5432 + +# 시놀로지 볼륨 경로 (기본값 사용 권장) +SYNOLOGY_UPLOADS_PATH=/volume1/todo-project/uploads +SYNOLOGY_CONFIG_PATH=/volume3/docker/todo-project/config +SYNOLOGY_DB_PATH=/volume3/docker/todo-project/postgres + +# CORS 설정 (시놀로지 IP로 변경) +CORS_ORIGINS=["http://192.168.1.100:4000", "http://localhost:4000"] + +# 프로덕션 설정 +DEBUG=false +``` + +### 3. 마이그레이션 파일 복사 + +```bash +# 데이터베이스 초기화 스크립트 복사 +cp -r backend/migrations/* /volume3/docker/todo-project/config/migrations/ +``` + +--- + +## 🚀 배포 실행 + +### 1. Docker Compose 실행 + +```bash +cd /volume3/docker/todo-project/app + +# 이미지 빌드 및 컨테이너 시작 +docker-compose up -d --build + +# 로그 확인 +docker-compose logs -f +``` + +### 2. 컨테이너 상태 확인 + +```bash +# 컨테이너 상태 확인 +docker-compose ps + +# 개별 서비스 로그 확인 +docker-compose logs backend +docker-compose logs frontend +docker-compose logs database +``` + +### 3. 헬스체크 확인 + +```bash +# 백엔드 API 확인 +curl http://localhost:9000/health + +# 데이터베이스 연결 확인 +docker-compose exec database pg_isready -U todo_user -d todo_db +``` + +--- + +## 🌐 접속 및 확인 + +### 1. 웹 인터페이스 접속 + +브라우저에서 다음 주소로 접속: +- **로컬**: `http://시놀로지IP:4000` +- **예시**: `http://192.168.1.100:4000` + +### 2. 초기 설정 + +1. **관리자 계정 생성**: 최초 접속 시 관리자 계정을 설정합니다 +2. **로그인**: 생성한 계정으로 로그인 +3. **기능 테스트**: 메모 작성, 이미지 업로드 등 기본 기능 확인 + +### 3. 리버스 프록시 설정 (선택사항) + +DSM > 제어판 > 로그인 포털 > 고급 > 리버스 프록시에서: + +``` +소스: +- 프로토콜: HTTPS +- 호스트 이름: your-domain.synology.me +- 포트: 443 +- 경로: /todo + +대상: +- 프로토콜: HTTP +- 호스트 이름: localhost +- 포트: 4000 +``` + +--- + +## 🔧 문제 해결 + +### 일반적인 문제들 + +#### 1. 컨테이너가 시작되지 않는 경우 + +```bash +# 로그 확인 +docker-compose logs + +# 개별 컨테이너 재시작 +docker-compose restart backend +docker-compose restart database +``` + +#### 2. 데이터베이스 연결 오류 + +```bash +# 데이터베이스 컨테이너 상태 확인 +docker-compose exec database pg_isready -U todo_user + +# 환경 변수 확인 +docker-compose exec backend env | grep DATABASE_URL +``` + +#### 3. 이미지 업로드 실패 + +```bash +# 업로드 디렉토리 권한 확인 +ls -la /volume1/todo-project/uploads/ + +# 권한 수정 +sudo chown -R 1000:1000 /volume1/todo-project/uploads/ +sudo chmod -R 755 /volume1/todo-project/uploads/ +``` + +#### 4. CORS 오류 + +`.env` 파일에서 `CORS_ORIGINS`에 시놀로지 IP 추가: +```bash +CORS_ORIGINS=["http://192.168.1.100:4000", "https://your-domain.synology.me"] +``` + +### 로그 확인 명령어 + +```bash +# 전체 로그 +docker-compose logs -f + +# 특정 서비스 로그 +docker-compose logs -f backend +docker-compose logs -f database + +# 최근 로그만 +docker-compose logs --tail=50 backend +``` + +--- + +## 🔄 유지보수 + +### 업데이트 + +```bash +cd /volume3/docker/todo-project/app + +# Git으로 최신 코드 가져오기 +git pull origin main + +# 컨테이너 재빌드 및 재시작 +docker-compose down +docker-compose up -d --build +``` + +### 백업 + +```bash +# 데이터베이스 백업 +docker-compose exec database pg_dump -U todo_user todo_db > backup_$(date +%Y%m%d).sql + +# 업로드된 이미지 백업 +tar -czf uploads_backup_$(date +%Y%m%d).tar.gz /volume1/todo-project/uploads/ +``` + +### 모니터링 + +```bash +# 컨테이너 리소스 사용량 +docker stats + +# 디스크 사용량 +df -h /volume1/todo-project/ +df -h /volume3/docker/todo-project/ +``` + +--- + +## 📞 지원 + +문제가 발생하면 다음을 확인하세요: + +1. **로그 파일**: `docker-compose logs` +2. **포트 충돌**: `netstat -tulpn | grep :4000` +3. **디스크 공간**: `df -h` +4. **메모리 사용량**: `free -m` + +--- + +## 🎯 성능 최적화 팁 + +### 1. 볼륨 배치 최적화 +- **이미지 저장소**: volume1 (SSD 권장) - 빠른 액세스 +- **데이터베이스**: volume3 (HDD 가능) - 대용량 저장 + +### 2. 메모리 설정 +- 최소 2GB RAM 할당 +- PostgreSQL shared_buffers 조정 + +### 3. 네트워크 최적화 +- 리버스 프록시 사용으로 HTTPS 적용 +- CDN 사용 고려 (정적 파일) + +--- + +**🎉 축하합니다! Todo-Project가 시놀로지에 성공적으로 배포되었습니다!** diff --git a/SYNOLOGY_INSTALL.md b/SYNOLOGY_INSTALL.md deleted file mode 100644 index 4a035dd..0000000 --- a/SYNOLOGY_INSTALL.md +++ /dev/null @@ -1,285 +0,0 @@ -# 🏠 시놀로지 NAS 설치 가이드 - -## 📋 사전 준비사항 - -### 1. 시놀로지 NAS 요구사항 -- **DSM 7.0 이상** -- **Container Manager** 패키지 설치 -- **Git Server** 패키지 설치 (선택사항) -- **최소 2GB RAM** 권장 - -### 2. 필요한 포트 -- **4000**: 프론트엔드 (Nginx) -- **8000**: 백엔드 API (FastAPI) -- **5432**: PostgreSQL (내부 통신) - -## 🚀 설치 방법 - -### 방법 1: SSH를 통한 설치 (권장) - -#### 1단계: SSH 접속 -```bash -ssh admin@[시놀로지_IP주소] -``` - -#### 2단계: 프로젝트 디렉토리 생성 -```bash -sudo mkdir -p /volume1/docker/todo-project -cd /volume1/docker/todo-project -``` - -#### 3단계: Git 클론 -```bash -sudo git clone https://git.hyungi.net/hyungi/Todo-Project.git . -``` - -#### 4단계: 환경 설정 -```bash -# 환경 변수 파일 생성 -sudo cp .env.example .env - -# 환경 변수 편집 (필요시) -sudo nano .env -``` - -#### 5단계: Docker 컨테이너 실행 -```bash -sudo docker-compose up -d -``` - -#### 6단계: 설치 확인 -```bash -# 컨테이너 상태 확인 -sudo docker-compose ps - -# 로그 확인 -sudo docker-compose logs -f -``` - -### 방법 2: Container Manager GUI 사용 - -#### 1단계: Container Manager 열기 -- DSM → **Package Center** → **Container Manager** 설치/실행 - -#### 2단계: 프로젝트 생성 -- **Project** → **Create** -- **Project name**: `todo-project` -- **Path**: `/docker/todo-project` - -#### 3단계: 소스 설정 -- **Source**: `Git Repository` -- **Repository URL**: `https://git.hyungi.net/hyungi/Todo-Project.git` -- **Branch**: `main` - -#### 4단계: 컨테이너 실행 -- **Build and run** 클릭 -- 자동으로 `docker-compose.yml` 파일을 읽어서 실행 - -## 🔧 설정 및 최적화 - -### 1. 환경 변수 설정 (.env) -```bash -# 데이터베이스 설정 -POSTGRES_DB=todo_project -POSTGRES_USER=todo_user -POSTGRES_PASSWORD=your_secure_password_here - -# JWT 설정 -SECRET_KEY=your_jwt_secret_key_here -ACCESS_TOKEN_EXPIRE_MINUTES=30 - -# 시놀로지 연동 설정 -SYNOLOGY_DSM_HOST=localhost -SYNOLOGY_DSM_PORT=5000 -SYNOLOGY_DSM_USERNAME=your_username -SYNOLOGY_DSM_PASSWORD=your_password - -# 메일 설정 (MailPlus 연동용) -MAIL_SERVER=localhost -MAIL_PORT=587 -MAIL_USERNAME=your_mail_username -MAIL_PASSWORD=your_mail_password -MAIL_FROM=noreply@yourdomain.com -``` - -### 2. 볼륨 매핑 확인 -```yaml -volumes: - - /volume1/docker/todo-project/data:/data - - todo_uploads:/data/uploads -``` - -### 3. 포트 포워딩 설정 -- **제어판** → **외부 액세스** → **라우터 구성** -- 포트 4000을 외부에서 접근 가능하도록 설정 - -## 🌐 접속 방법 - -### 내부 네트워크 -``` -http://[시놀로지_IP]:4000 -``` - -### 외부 접속 (DDNS 설정 시) -``` -http://[your-synology-ddns].synology.me:4000 -``` - -## 🔒 보안 설정 - -### 1. HTTPS 설정 (Let's Encrypt) -```bash -# 인증서 디렉토리 생성 -sudo mkdir -p /volume1/docker/todo-project/ssl - -# docker-compose.yml에 SSL 볼륨 추가 -volumes: - - /volume1/ssl:/etc/ssl/certs -``` - -### 2. 방화벽 설정 -- **제어판** → **보안** → **방화벽** -- 필요한 포트만 열기: 4000, 8000 - -### 3. 사용자 권한 설정 -```bash -# Docker 그룹에 사용자 추가 -sudo usermod -aG docker $USER -``` - -## 📊 모니터링 및 유지보수 - -### 1. 로그 확인 -```bash -# 전체 로그 -sudo docker-compose logs - -# 특정 서비스 로그 -sudo docker-compose logs frontend -sudo docker-compose logs backend -sudo docker-compose logs db -``` - -### 2. 컨테이너 상태 확인 -```bash -# 실행 중인 컨테이너 -sudo docker-compose ps - -# 리소스 사용량 -sudo docker stats -``` - -### 3. 업데이트 -```bash -# 최신 코드 가져오기 -sudo git pull origin main - -# 컨테이너 재빌드 -sudo docker-compose down -sudo docker-compose up -d --build -``` - -### 4. 백업 -```bash -# 데이터베이스 백업 -sudo docker-compose exec db pg_dump -U todo_user todo_project > backup.sql - -# 업로드 파일 백업 -sudo tar -czf uploads_backup.tar.gz /volume1/docker/todo-project/data/uploads -``` - -## 🔧 문제 해결 - -### 1. 컨테이너가 시작되지 않는 경우 -```bash -# 로그 확인 -sudo docker-compose logs - -# 포트 충돌 확인 -sudo netstat -tulpn | grep :4000 -``` - -### 2. 데이터베이스 연결 오류 -```bash -# PostgreSQL 컨테이너 상태 확인 -sudo docker-compose exec db psql -U todo_user -d todo_project -c "SELECT 1;" -``` - -### 3. 권한 문제 -```bash -# 파일 권한 수정 -sudo chown -R 1000:1000 /volume1/docker/todo-project/data -``` - -### 4. 메모리 부족 -```bash -# 메모리 사용량 확인 -free -h - -# Docker 메모리 제한 설정 (docker-compose.yml) -services: - backend: - mem_limit: 512m -``` - -## 📱 PWA 설정 - -### 1. HTTPS 필수 -- PWA 기능을 위해서는 HTTPS 연결이 필요합니다 -- Let's Encrypt 인증서 설정 권장 - -### 2. 홈화면 추가 -- 모바일에서 Safari/Chrome으로 접속 -- "홈화면에 추가" 선택 -- 네이티브 앱처럼 사용 가능 - -## 🎯 성능 최적화 - -### 1. Nginx 캐싱 설정 -```nginx -location /static/ { - expires 1y; - add_header Cache-Control "public, immutable"; -} -``` - -### 2. 데이터베이스 최적화 -```sql --- 인덱스 생성 -CREATE INDEX idx_todos_created_at ON todos(created_at); -CREATE INDEX idx_todos_status ON todos(status); -``` - -### 3. 이미지 최적화 -- 업로드된 이미지는 자동으로 압축됩니다 -- 최대 1920x1920 해상도로 리사이즈 -- JPEG 품질 85%로 최적화 - -## 📞 지원 - -문제가 발생하면 다음을 확인해주세요: -1. **로그 파일**: `sudo docker-compose logs` -2. **시스템 리소스**: `htop` 또는 DSM 리소스 모니터 -3. **네트워크 연결**: 포트 접근 가능 여부 -4. **권한 설정**: 파일 및 디렉토리 권한 - ---- - -## 🚀 빠른 시작 명령어 - -```bash -# 전체 설치 (한 번에 실행) -ssh admin@[시놀로지_IP] -sudo mkdir -p /volume1/docker/todo-project -cd /volume1/docker/todo-project -sudo git clone https://git.hyungi.net/hyungi/Todo-Project.git . -sudo cp .env.example .env -sudo docker-compose up -d - -# 접속 확인 -curl http://localhost:4000 -``` - -설치 완료 후 `http://[시놀로지_IP]:4000`으로 접속하여 Todo Project를 사용하세요! 🎉 - - diff --git a/backend/Dockerfile b/backend/Dockerfile index 9accb5d..cec7bbe 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -5,6 +5,7 @@ WORKDIR /app # 시스템 패키지 업데이트 및 필요한 패키지 설치 RUN apt-get update && apt-get install -y \ gcc \ + curl \ && rm -rf /var/lib/apt/lists/* # Python 의존성 설치 diff --git a/backend/migrations/003_optimize_for_workflow.sql b/backend/migrations/003_optimize_for_workflow.sql new file mode 100644 index 0000000..de1df92 --- /dev/null +++ b/backend/migrations/003_optimize_for_workflow.sql @@ -0,0 +1,38 @@ +-- 새로운 워크플로우에 맞게 DB 구조 최적화 + +-- 1. due_date를 start_date로 변경 +ALTER TABLE todos RENAME COLUMN due_date TO start_date; + +-- 2. tags 컬럼 제거 (사용하지 않음) +ALTER TABLE todos DROP COLUMN IF EXISTS tags; + +-- 3. category 기본값 변경 및 기존 데이터 정리 +-- 기존 'checklist' 카테고리를 'memo'로 변경 +UPDATE todos SET category = 'memo' WHERE category = 'checklist'; + +-- 기존 'calendar' 카테고리를 'todo'로 변경 +UPDATE todos SET category = 'todo' WHERE category = 'calendar'; + +-- 4. title을 nullable로 변경 (메모의 경우 선택사항) +ALTER TABLE todos ALTER COLUMN title DROP NOT NULL; + +-- 5. description을 NOT NULL로 변경 (내용은 필수) +UPDATE todos SET description = COALESCE(title, '내용 없음') WHERE description IS NULL OR description = ''; +ALTER TABLE todos ALTER COLUMN description SET NOT NULL; + +-- 6. category 기본값을 'memo'로 설정 +ALTER TABLE todos ALTER COLUMN category SET DEFAULT 'memo'; + +-- 7. 불필요한 인덱스 정리 및 새로운 인덱스 추가 +-- 기존 due_date 인덱스 제거 (컬럼명이 변경됨) +DROP INDEX IF EXISTS idx_todos_due_date; + +-- 새로운 인덱스 생성 +CREATE INDEX IF NOT EXISTS idx_todos_start_date ON todos(start_date); +CREATE INDEX IF NOT EXISTS idx_todos_category_status ON todos(category, status); +CREATE INDEX IF NOT EXISTS idx_todos_user_category ON todos(user_id, category); + +-- 8. 성능 최적화를 위한 복합 인덱스 +CREATE INDEX IF NOT EXISTS idx_todos_workflow ON todos(user_id, category, status, start_date); + +COMMIT; diff --git a/backend/src/api/routes/calendar.py b/backend/src/api/routes/calendar.py index 675e22e..7dc394a 100644 --- a/backend/src/api/routes/calendar.py +++ b/backend/src/api/routes/calendar.py @@ -1,7 +1,8 @@ """ 간단한 캘린더 API 라우터 """ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException +from fastapi import status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, and_ from typing import List @@ -32,10 +33,10 @@ async def get_calendar_todos( query = select(Todo).where( and_( Todo.user_id == current_user.id, - Todo.due_date >= start_date, - Todo.due_date <= end_date + Todo.start_date >= start_date, + Todo.start_date <= end_date ) - ).order_by(Todo.due_date.asc()) + ).order_by(Todo.start_date.asc()) result = await db.execute(query) todos = result.scalars().all() @@ -62,7 +63,7 @@ async def get_today_todos( query = select(Todo).where( and_( Todo.user_id == current_user.id, - Todo.due_date == today + Todo.start_date == today ) ).order_by(Todo.created_at.desc()) diff --git a/backend/src/api/routes/setup.py b/backend/src/api/routes/setup.py new file mode 100644 index 0000000..a3b861c --- /dev/null +++ b/backend/src/api/routes/setup.py @@ -0,0 +1,121 @@ +""" +초기 설정 관련 API 라우터 +- 최초 관리자 계정 설정 +- 시스템 초기화 상태 확인 +""" +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from pydantic import BaseModel, Field + +from ...core.database import get_db +from ...core.security import get_password_hash +from ...models.user import User +from ...schemas.auth import CreateUserRequest + + +router = APIRouter() + + +class InitialSetupRequest(BaseModel): + """초기 설정 요청""" + admin_username: str = Field(..., min_length=3, max_length=50, description="관리자 사용자명") + admin_email: str = Field(..., description="관리자 이메일") + admin_password: str = Field(..., min_length=6, description="관리자 비밀번호") + admin_full_name: str = Field(default="Administrator", description="관리자 이름") + + +class SetupStatusResponse(BaseModel): + """설정 상태 응답""" + is_setup_required: bool + user_count: int + + +@router.get("/status", response_model=SetupStatusResponse) +async def get_setup_status(db: AsyncSession = Depends(get_db)): + """시스템 초기 설정 필요 여부 확인""" + try: + # 사용자 수 확인 + result = await db.execute(select(func.count(User.id))) + user_count = result.scalar() or 0 + + return SetupStatusResponse( + is_setup_required=user_count == 0, + user_count=user_count + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="설정 상태 확인 중 오류가 발생했습니다." + ) + + +@router.post("/initialize") +async def initialize_system( + setup_data: InitialSetupRequest, + db: AsyncSession = Depends(get_db) +): + """시스템 초기화 및 관리자 계정 생성""" + try: + # 이미 사용자가 있는지 확인 + result = await db.execute(select(func.count(User.id))) + user_count = result.scalar() or 0 + + if user_count > 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="시스템이 이미 초기화되었습니다." + ) + + # 사용자명 중복 확인 + existing_user = await db.execute( + select(User).where(User.username == setup_data.admin_username) + ) + if existing_user.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="이미 존재하는 사용자명입니다." + ) + + # 이메일 중복 확인 + existing_email = await db.execute( + select(User).where(User.email == setup_data.admin_email) + ) + if existing_email.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="이미 존재하는 이메일입니다." + ) + + # 관리자 계정 생성 + admin_user = User( + username=setup_data.admin_username, + email=setup_data.admin_email, + hashed_password=get_password_hash(setup_data.admin_password), + full_name=setup_data.admin_full_name, + is_active=True, + is_admin=True + ) + + db.add(admin_user) + await db.commit() + await db.refresh(admin_user) + + return { + "message": "시스템이 성공적으로 초기화되었습니다.", + "admin_user": { + "id": str(admin_user.id), + "username": admin_user.username, + "email": admin_user.email, + "full_name": admin_user.full_name + } + } + + except HTTPException: + raise + except Exception as e: + await db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="시스템 초기화 중 오류가 발생했습니다." + ) diff --git a/backend/src/api/routes/todos.py b/backend/src/api/routes/todos.py index f750ea0..3c0b861 100644 --- a/backend/src/api/routes/todos.py +++ b/backend/src/api/routes/todos.py @@ -1,7 +1,7 @@ """ 간단한 Todo API 라우터 """ -from fastapi import APIRouter, Depends, HTTPException, status, Query, UploadFile, File +from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, and_ from typing import List, Optional @@ -14,11 +14,54 @@ from ...models.user import User from ...models.todo import Todo, TodoStatus from ...schemas.todo import TodoCreate, TodoUpdate, TodoResponse from ..dependencies import get_current_active_user +from ...services.file_service import save_image logger = logging.getLogger(__name__) router = APIRouter(prefix="/todos", tags=["todos"]) +@router.post("/upload-image") +async def upload_image( + image: UploadFile = File(...), + current_user: User = Depends(get_current_active_user) +): + """이미지 파일 업로드""" + try: + # 파일 타입 검증 + if not image.content_type.startswith('image/'): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="이미지 파일만 업로드 가능합니다." + ) + + # 파일 크기 검증 (10MB 제한) + content = await image.read() + if len(content) > 10 * 1024 * 1024: # 10MB + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="파일 크기는 10MB를 초과할 수 없습니다." + ) + + # 이미지 저장 + file_url = save_image(content, image.filename) + if not file_url: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="이미지 저장에 실패했습니다." + ) + + return {"file_url": file_url} + + except HTTPException: + raise + except Exception as e: + logger.error(f"이미지 업로드 실패: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="이미지 업로드 중 오류가 발생했습니다." + ) + + # ============================================================================ # Todo CRUD API # ============================================================================ @@ -34,15 +77,17 @@ async def create_todo( logger.info(f"Todo 생성 요청 - 사용자: {current_user.username}") logger.info(f"요청 데이터: {todo_data.dict()}") - # 날짜 문자열 파싱 (한국 시간 형식) - parsed_due_date = None - if todo_data.due_date: + # 시작 날짜 문자열 파싱 (Todo일 때만) + parsed_start_date = None + if todo_data.start_date and todo_data.category.value == "todo": try: - # "2025-09-22T00:00:00+09:00" 형식 파싱 - parsed_due_date = datetime.fromisoformat(todo_data.due_date) - logger.info(f"파싱된 날짜: {parsed_due_date}") + # "2025-09-22" 형식 파싱 후 한국 시간으로 변환 + from datetime import date + date_obj = datetime.strptime(todo_data.start_date, "%Y-%m-%d").date() + parsed_start_date = datetime.combine(date_obj, datetime.min.time()) + logger.info(f"파싱된 시작 날짜: {parsed_start_date}") except ValueError: - logger.warning(f"Invalid date format: {todo_data.due_date}") + logger.warning(f"Invalid date format: {todo_data.start_date}") # 이미지 URLs JSON 변환 import json @@ -56,9 +101,10 @@ async def create_todo( title=todo_data.title, description=todo_data.description, category=todo_data.category, - due_date=parsed_due_date, + start_date=parsed_start_date, image_urls=image_urls_json, - tags=todo_data.tags + board_id=todo_data.board_id, + is_board_header=todo_data.is_board_header or False ) db.add(new_todo) @@ -83,10 +129,11 @@ async def create_todo( "status": new_todo.status, "created_at": new_todo.created_at, "updated_at": new_todo.updated_at, - "due_date": new_todo.due_date.isoformat() if new_todo.due_date else None, + "start_date": new_todo.start_date.strftime("%Y-%m-%d") if new_todo.start_date else None, "completed_at": new_todo.completed_at, "image_urls": image_urls_list, - "tags": new_todo.tags + "board_id": str(new_todo.board_id) if new_todo.board_id else None, + "is_board_header": new_todo.is_board_header } return response_data @@ -146,10 +193,11 @@ async def get_todos( "status": todo.status, "created_at": todo.created_at, "updated_at": todo.updated_at, - "due_date": todo.due_date.isoformat() if todo.due_date else None, + "start_date": todo.start_date.isoformat() if todo.start_date else None, "completed_at": todo.completed_at, "image_urls": image_urls_list, - "tags": todo.tags + "board_id": str(todo.board_id) if todo.board_id else None, + "is_board_header": todo.is_board_header }) return response_data @@ -201,10 +249,11 @@ async def get_todo( "status": todo.status, "created_at": todo.created_at, "updated_at": todo.updated_at, - "due_date": todo.due_date.isoformat() if todo.due_date else None, + "start_date": todo.start_date.isoformat() if todo.start_date else None, "completed_at": todo.completed_at, "image_urls": image_urls_list, - "tags": todo.tags, + "board_id": str(todo.board_id) if todo.board_id else None, + "is_board_header": todo.is_board_header } return response_data @@ -246,7 +295,7 @@ async def update_todo( import json update_data = todo_data.dict(exclude_unset=True) for field, value in update_data.items(): - if field == 'due_date' and value: + if field == 'start_date' and value: # 날짜 문자열 파싱 (한국 시간 형식) try: parsed_date = datetime.fromisoformat(value) @@ -285,10 +334,11 @@ async def update_todo( "status": todo.status, "created_at": todo.created_at, "updated_at": todo.updated_at, - "due_date": todo.due_date.isoformat() if todo.due_date else None, + "start_date": todo.start_date.isoformat() if todo.start_date else None, "completed_at": todo.completed_at, "image_urls": image_urls_list, - "tags": todo.tags, + "board_id": str(todo.board_id) if todo.board_id else None, + "is_board_header": todo.is_board_header } return response_data diff --git a/backend/src/core/config.py b/backend/src/core/config.py index ed4f263..e626a06 100644 --- a/backend/src/core/config.py +++ b/backend/src/core/config.py @@ -24,8 +24,8 @@ class Settings(BaseSettings): ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 REFRESH_TOKEN_EXPIRE_DAYS: int = 7 - # CORS 설정 - ALLOWED_HOSTS: List[str] = ["http://localhost:4000", "http://127.0.0.1:4000"] + # CORS 설정 (환경변수로 오버라이드 가능) + ALLOWED_HOSTS: List[str] = ["localhost", "127.0.0.1"] ALLOWED_ORIGINS: List[str] = ["http://localhost:4000", "http://127.0.0.1:4000"] # 서버 설정 diff --git a/backend/src/core/database.py b/backend/src/core/database.py index a2e5dd4..f62fd49 100644 --- a/backend/src/core/database.py +++ b/backend/src/core/database.py @@ -68,28 +68,16 @@ async def init_db() -> None: async def create_admin_user() -> None: - """관리자 계정 생성 (존재하지 않을 경우)""" + """관리자 계정 생성 (존재하지 않을 경우) - 초기 설정 API로 대체됨""" from ..models.user import User - from .security import get_password_hash - from sqlalchemy import select + from sqlalchemy import select, func async with AsyncSessionLocal() as session: - # 관리자 계정 존재 확인 - result = await session.execute( - select(User).where(User.username == settings.ADMIN_USERNAME) - ) - admin_user = result.scalar_one_or_none() + # 사용자 수 확인 + result = await session.execute(select(func.count(User.id))) + user_count = result.scalar() or 0 - if not admin_user: - # 관리자 계정 생성 - admin_user = User( - username=settings.ADMIN_USERNAME, - email=settings.ADMIN_EMAIL, - hashed_password=get_password_hash(settings.ADMIN_PASSWORD), - is_active=True, - is_admin=True, - full_name="Administrator" - ) - session.add(admin_user) - await session.commit() - print(f"관리자 계정이 생성되었습니다: {settings.ADMIN_USERNAME}") + if user_count == 0: + print("초기 설정이 필요합니다. /api/setup/status 엔드포인트를 확인하세요.") + else: + print(f"시스템에 {user_count}명의 사용자가 등록되어 있습니다.") diff --git a/backend/src/integrations/__init__.py b/backend/src/integrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/src/integrations/calendar/__init__.py b/backend/src/integrations/calendar/__init__.py deleted file mode 100644 index ffac1ff..0000000 --- a/backend/src/integrations/calendar/__init__.py +++ /dev/null @@ -1,32 +0,0 @@ -""" -캘린더 통합 모듈 -- 다중 캘린더 제공자 지원 (시놀로지, 애플, 구글 등) -- 간결한 API로 Todo 항목을 캘린더에 동기화 -""" - -from .base import BaseCalendarService, CalendarProvider -from .synology import SynologyCalendarService -from .apple import AppleCalendarService, create_apple_service, format_todo_for_apple -from .router import CalendarRouter, get_calendar_router, setup_calendar_providers - -__all__ = [ - # 기본 인터페이스 - "BaseCalendarService", - "CalendarProvider", - - # 서비스 구현체 - "SynologyCalendarService", - "AppleCalendarService", - - # 라우터 및 관리 - "CalendarRouter", - "get_calendar_router", - "setup_calendar_providers", - - # 편의 함수 - "create_apple_service", - "format_todo_for_apple", -] - -# 버전 정보 -__version__ = "1.0.0" diff --git a/backend/src/integrations/calendar/apple.py b/backend/src/integrations/calendar/apple.py deleted file mode 100644 index e235787..0000000 --- a/backend/src/integrations/calendar/apple.py +++ /dev/null @@ -1,370 +0,0 @@ -""" -Apple iCloud Calendar 서비스 구현 -- 통합 서비스 파일 기준: 최대 500줄 -- 간결함 원칙: 한 파일에서 모든 Apple 캘린더 기능 제공 -""" -import asyncio -import aiohttp -from typing import Dict, List, Any, Optional -from datetime import datetime, timedelta -import logging -from urllib.parse import urljoin -import xml.etree.ElementTree as ET -from base64 import b64encode - -from .base import BaseCalendarService, CalendarProvider - -logger = logging.getLogger(__name__) - - -class AppleCalendarService(BaseCalendarService): - """Apple iCloud 캘린더 서비스 (CalDAV 기반)""" - - def __init__(self): - self.base_url = "https://caldav.icloud.com" - self.session: Optional[aiohttp.ClientSession] = None - self.auth_header: Optional[str] = None - self.principal_url: Optional[str] = None - self.calendar_home_url: Optional[str] = None - - async def authenticate(self, credentials: Dict[str, Any]) -> bool: - """ - Apple ID 및 앱 전용 암호로 인증 - credentials: {"apple_id": "user@icloud.com", "app_password": "xxxx-xxxx-xxxx-xxxx"} - """ - try: - apple_id = credentials.get("apple_id") - app_password = credentials.get("app_password") - - if not apple_id or not app_password: - logger.error("Apple ID 또는 앱 전용 암호가 누락됨") - return False - - # Basic Auth 헤더 생성 - auth_string = f"{apple_id}:{app_password}" - auth_bytes = auth_string.encode('utf-8') - self.auth_header = f"Basic {b64encode(auth_bytes).decode('utf-8')}" - - # HTTP 세션 생성 - self.session = aiohttp.ClientSession( - headers={"Authorization": self.auth_header} - ) - - # Principal URL 찾기 - if not await self._discover_principal(): - return False - - # Calendar Home URL 찾기 - if not await self._discover_calendar_home(): - return False - - logger.info(f"Apple 캘린더 인증 성공: {apple_id}") - return True - - except Exception as e: - logger.error(f"Apple 캘린더 인증 실패: {e}") - return False - - async def _discover_principal(self) -> bool: - """Principal URL 검색 (CalDAV 표준)""" - try: - propfind_body = """ - - - - - """ - - async with self.session.request( - "PROPFIND", - self.base_url, - data=propfind_body, - headers={"Content-Type": "application/xml", "Depth": "0"} - ) as response: - - if response.status != 207: - logger.error(f"Principal 검색 실패: {response.status}") - return False - - xml_content = await response.text() - root = ET.fromstring(xml_content) - - # Principal URL 추출 - for elem in root.iter(): - if elem.tag.endswith("current-user-principal"): - href = elem.find(".//{DAV:}href") - if href is not None: - self.principal_url = urljoin(self.base_url, href.text) - return True - - logger.error("Principal URL을 찾을 수 없음") - return False - - except Exception as e: - logger.error(f"Principal 검색 중 오류: {e}") - return False - - async def _discover_calendar_home(self) -> bool: - """Calendar Home URL 검색""" - try: - propfind_body = """ - - - - - """ - - async with self.session.request( - "PROPFIND", - self.principal_url, - data=propfind_body, - headers={"Content-Type": "application/xml", "Depth": "0"} - ) as response: - - if response.status != 207: - logger.error(f"Calendar Home 검색 실패: {response.status}") - return False - - xml_content = await response.text() - root = ET.fromstring(xml_content) - - # Calendar Home URL 추출 - for elem in root.iter(): - if elem.tag.endswith("calendar-home-set"): - href = elem.find(".//{DAV:}href") - if href is not None: - self.calendar_home_url = urljoin(self.base_url, href.text) - return True - - logger.error("Calendar Home URL을 찾을 수 없음") - return False - - except Exception as e: - logger.error(f"Calendar Home 검색 중 오류: {e}") - return False - - async def get_calendars(self) -> List[Dict[str, Any]]: - """사용 가능한 캘린더 목록 조회""" - try: - propfind_body = """ - - - - - - - - """ - - async with self.session.request( - "PROPFIND", - self.calendar_home_url, - data=propfind_body, - headers={"Content-Type": "application/xml", "Depth": "1"} - ) as response: - - if response.status != 207: - logger.error(f"캘린더 목록 조회 실패: {response.status}") - return [] - - xml_content = await response.text() - return self._parse_calendars(xml_content) - - except Exception as e: - logger.error(f"캘린더 목록 조회 중 오류: {e}") - return [] - - def _parse_calendars(self, xml_content: str) -> List[Dict[str, Any]]: - """캘린더 XML 응답 파싱""" - calendars = [] - root = ET.fromstring(xml_content) - - for response in root.findall(".//{DAV:}response"): - # 캘린더인지 확인 - resourcetype = response.find(".//{DAV:}resourcetype") - if resourcetype is None: - continue - - is_calendar = resourcetype.find(".//{urn:ietf:params:xml:ns:caldav}calendar") is not None - if not is_calendar: - continue - - # 캘린더 정보 추출 - href_elem = response.find(".//{DAV:}href") - name_elem = response.find(".//{DAV:}displayname") - desc_elem = response.find(".//{urn:ietf:params:xml:ns:caldav}calendar-description") - - if href_elem is not None and name_elem is not None: - calendar = { - "id": href_elem.text.split("/")[-2], # URL에서 ID 추출 - "name": name_elem.text or "이름 없음", - "description": desc_elem.text if desc_elem is not None else "", - "url": urljoin(self.base_url, href_elem.text), - "provider": CalendarProvider.APPLE.value - } - calendars.append(calendar) - - return calendars - - async def create_event(self, calendar_id: str, event_data: Dict[str, Any]) -> Dict[str, Any]: - """이벤트 생성 (iCalendar 형식)""" - try: - # iCalendar 이벤트 생성 - ics_content = self._create_ics_event(event_data) - - # 이벤트 URL 생성 (UUID 기반) - import uuid - event_id = str(uuid.uuid4()) - event_url = f"{self.calendar_home_url}{calendar_id}/{event_id}.ics" - - # PUT 요청으로 이벤트 생성 - async with self.session.put( - event_url, - data=ics_content, - headers={"Content-Type": "text/calendar; charset=utf-8"} - ) as response: - - if response.status not in [201, 204]: - logger.error(f"이벤트 생성 실패: {response.status}") - return {} - - logger.info(f"Apple 캘린더 이벤트 생성 성공: {event_id}") - return { - "id": event_id, - "url": event_url, - "provider": CalendarProvider.APPLE.value - } - - except Exception as e: - logger.error(f"Apple 캘린더 이벤트 생성 중 오류: {e}") - return {} - - def _create_ics_event(self, event_data: Dict[str, Any]) -> str: - """iCalendar 형식 이벤트 생성""" - title = event_data.get("title", "제목 없음") - description = event_data.get("description", "") - start_time = event_data.get("start_time") - end_time = event_data.get("end_time") - - # 시간 형식 변환 - if isinstance(start_time, datetime): - start_str = start_time.strftime("%Y%m%dT%H%M%SZ") - else: - start_str = datetime.now().strftime("%Y%m%dT%H%M%SZ") - - if isinstance(end_time, datetime): - end_str = end_time.strftime("%Y%m%dT%H%M%SZ") - else: - end_str = (datetime.now() + timedelta(hours=1)).strftime("%Y%m%dT%H%M%SZ") - - # iCalendar 내용 생성 - ics_content = f"""BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Todo-Project//Apple Calendar//KO -BEGIN:VEVENT -UID:{event_data.get('uid', str(uuid.uuid4()))} -DTSTART:{start_str} -DTEND:{end_str} -SUMMARY:{title} -DESCRIPTION:{description} -CREATED:{datetime.now().strftime("%Y%m%dT%H%M%SZ")} -LAST-MODIFIED:{datetime.now().strftime("%Y%m%dT%H%M%SZ")} -END:VEVENT -END:VCALENDAR""" - - return ics_content - - async def update_event(self, event_id: str, event_data: Dict[str, Any]) -> Dict[str, Any]: - """이벤트 수정""" - try: - # 기존 이벤트 URL 구성 - calendar_id = event_data.get("calendar_id", "calendar") - event_url = f"{self.calendar_home_url}{calendar_id}/{event_id}.ics" - - # 수정된 iCalendar 내용 생성 - ics_content = self._create_ics_event(event_data) - - # PUT 요청으로 이벤트 수정 - async with self.session.put( - event_url, - data=ics_content, - headers={"Content-Type": "text/calendar; charset=utf-8"} - ) as response: - - if response.status not in [200, 204]: - logger.error(f"이벤트 수정 실패: {response.status}") - return {} - - logger.info(f"Apple 캘린더 이벤트 수정 성공: {event_id}") - return { - "id": event_id, - "url": event_url, - "provider": CalendarProvider.APPLE.value - } - - except Exception as e: - logger.error(f"Apple 캘린더 이벤트 수정 중 오류: {e}") - return {} - - async def delete_event(self, event_id: str, calendar_id: str = "calendar") -> bool: - """이벤트 삭제""" - try: - # 이벤트 URL 구성 - event_url = f"{self.calendar_home_url}{calendar_id}/{event_id}.ics" - - # DELETE 요청 - async with self.session.delete(event_url) as response: - if response.status not in [200, 204, 404]: - logger.error(f"이벤트 삭제 실패: {response.status}") - return False - - logger.info(f"Apple 캘린더 이벤트 삭제 성공: {event_id}") - return True - - except Exception as e: - logger.error(f"Apple 캘린더 이벤트 삭제 중 오류: {e}") - return False - - async def close(self): - """세션 정리""" - if self.session: - await self.session.close() - self.session = None - - def __del__(self): - """소멸자에서 세션 정리""" - if self.session and not self.session.closed: - asyncio.create_task(self.close()) - - -# 편의 함수들 -async def create_apple_service(apple_id: str, app_password: str) -> Optional[AppleCalendarService]: - """Apple 캘린더 서비스 생성 및 인증""" - service = AppleCalendarService() - - credentials = { - "apple_id": apple_id, - "app_password": app_password - } - - if await service.authenticate(credentials): - return service - else: - await service.close() - return None - - -def format_todo_for_apple(todo_item) -> Dict[str, Any]: - """Todo 아이템을 Apple 캘린더 이벤트 형식으로 변환""" - import uuid - - return { - "uid": str(uuid.uuid4()), - "title": f"📋 {todo_item.content}", - "description": f"Todo 항목\n상태: {todo_item.status}\n생성일: {todo_item.created_at}", - "start_time": todo_item.start_date or todo_item.created_at, - "end_time": (todo_item.start_date or todo_item.created_at) + timedelta( - minutes=todo_item.estimated_minutes or 30 - ), - "categories": ["todo", "업무"] - } diff --git a/backend/src/integrations/calendar/base.py b/backend/src/integrations/calendar/base.py deleted file mode 100644 index c574c70..0000000 --- a/backend/src/integrations/calendar/base.py +++ /dev/null @@ -1,332 +0,0 @@ -""" -캘린더 서비스 기본 인터페이스 및 추상화 -""" -from abc import ABC, abstractmethod -from enum import Enum -from typing import Dict, List, Optional, Any -from datetime import datetime, timedelta -from dataclasses import dataclass -import uuid - - -class CalendarProvider(Enum): - """캘린더 제공자 열거형""" - SYNOLOGY = "synology" - APPLE = "apple" - GOOGLE = "google" - CALDAV = "caldav" # 일반 CalDAV 서버 - - -@dataclass -class CalendarInfo: - """캘린더 정보""" - id: str - name: str - color: str - description: Optional[str] = None - provider: CalendarProvider = CalendarProvider.CALDAV - is_default: bool = False - is_writable: bool = True - - -@dataclass -class CalendarEvent: - """캘린더 이벤트""" - id: Optional[str] = None - title: str = "" - description: Optional[str] = None - start_time: Optional[datetime] = None - end_time: Optional[datetime] = None - all_day: bool = False - location: Optional[str] = None - categories: List[str] = None - color: Optional[str] = None - reminder_minutes: Optional[int] = None - status: str = "TENTATIVE" # TENTATIVE, CONFIRMED, CANCELLED - - def __post_init__(self): - if self.categories is None: - self.categories = [] - if self.id is None: - self.id = str(uuid.uuid4()) - - -@dataclass -class CalendarCredentials: - """캘린더 인증 정보""" - provider: CalendarProvider - server_url: Optional[str] = None - username: Optional[str] = None - password: Optional[str] = None - app_password: Optional[str] = None # Apple 앱 전용 비밀번호 - oauth_token: Optional[str] = None # OAuth 토큰 - additional_params: Dict[str, Any] = None - - def __post_init__(self): - if self.additional_params is None: - self.additional_params = {} - - -class CalendarServiceError(Exception): - """캘린더 서비스 오류""" - pass - - -class AuthenticationError(CalendarServiceError): - """인증 오류""" - pass - - -class CalendarNotFoundError(CalendarServiceError): - """캘린더를 찾을 수 없음""" - pass - - -class EventNotFoundError(CalendarServiceError): - """이벤트를 찾을 수 없음""" - pass - - -class BaseCalendarService(ABC): - """캘린더 서비스 기본 인터페이스""" - - def __init__(self, credentials: CalendarCredentials): - self.credentials = credentials - self.provider = credentials.provider - self._authenticated = False - self._client = None - - @property - def is_authenticated(self) -> bool: - """인증 상태 확인""" - return self._authenticated - - @abstractmethod - async def authenticate(self) -> bool: - """ - 인증 수행 - - Returns: - bool: 인증 성공 여부 - - Raises: - AuthenticationError: 인증 실패 시 - """ - pass - - @abstractmethod - async def get_calendars(self) -> List[CalendarInfo]: - """ - 사용 가능한 캘린더 목록 조회 - - Returns: - List[CalendarInfo]: 캘린더 목록 - - Raises: - CalendarServiceError: 조회 실패 시 - """ - pass - - @abstractmethod - async def create_event(self, calendar_id: str, event: CalendarEvent) -> CalendarEvent: - """ - 이벤트 생성 - - Args: - calendar_id: 캘린더 ID - event: 생성할 이벤트 정보 - - Returns: - CalendarEvent: 생성된 이벤트 (ID 포함) - - Raises: - CalendarNotFoundError: 캘린더를 찾을 수 없음 - CalendarServiceError: 생성 실패 - """ - pass - - @abstractmethod - async def update_event(self, calendar_id: str, event: CalendarEvent) -> CalendarEvent: - """ - 이벤트 수정 - - Args: - calendar_id: 캘린더 ID - event: 수정할 이벤트 정보 (ID 포함) - - Returns: - CalendarEvent: 수정된 이벤트 - - Raises: - EventNotFoundError: 이벤트를 찾을 수 없음 - CalendarServiceError: 수정 실패 - """ - pass - - @abstractmethod - async def delete_event(self, calendar_id: str, event_id: str) -> bool: - """ - 이벤트 삭제 - - Args: - calendar_id: 캘린더 ID - event_id: 삭제할 이벤트 ID - - Returns: - bool: 삭제 성공 여부 - - Raises: - EventNotFoundError: 이벤트를 찾을 수 없음 - CalendarServiceError: 삭제 실패 - """ - pass - - @abstractmethod - async def get_event(self, calendar_id: str, event_id: str) -> CalendarEvent: - """ - 특정 이벤트 조회 - - Args: - calendar_id: 캘린더 ID - event_id: 이벤트 ID - - Returns: - CalendarEvent: 이벤트 정보 - - Raises: - EventNotFoundError: 이벤트를 찾을 수 없음 - """ - pass - - async def test_connection(self) -> Dict[str, Any]: - """ - 연결 테스트 - - Returns: - Dict: 테스트 결과 - """ - try: - success = await self.authenticate() - if success: - calendars = await self.get_calendars() - return { - "status": "success", - "provider": self.provider.value, - "calendar_count": len(calendars), - "calendars": [{"id": cal.id, "name": cal.name} for cal in calendars[:3]] # 처음 3개만 - } - else: - return { - "status": "failed", - "provider": self.provider.value, - "error": "Authentication failed" - } - except Exception as e: - return { - "status": "error", - "provider": self.provider.value, - "error": str(e) - } - - def _ensure_authenticated(self): - """인증 상태 확인 (내부 사용)""" - if not self._authenticated: - raise AuthenticationError(f"{self.provider.value} 서비스에 인증되지 않았습니다.") - - -class TodoEventConverter: - """Todo 아이템을 캘린더 이벤트로 변환하는 유틸리티""" - - @staticmethod - def todo_to_event(todo_item, provider: CalendarProvider) -> CalendarEvent: - """ - 할일을 캘린더 이벤트로 변환 - - Args: - todo_item: Todo 아이템 - provider: 캘린더 제공자 - - Returns: - CalendarEvent: 변환된 이벤트 - """ - # 상태별 아이콘 및 색상 - status_icons = { - "draft": "📝", - "scheduled": "📋", - "active": "🔥", - "completed": "✅", - "delayed": "⏰" - } - - status_colors = { - "draft": "#9ca3af", # 회색 - "scheduled": "#6366f1", # 보라색 - "active": "#f59e0b", # 주황색 - "completed": "#10b981", # 초록색 - "delayed": "#ef4444" # 빨간색 - } - - icon = status_icons.get(todo_item.status, "📋") - color = status_colors.get(todo_item.status, "#6366f1") - - # 시작/종료 시간 계산 - start_time = todo_item.start_date - end_time = start_time + timedelta(minutes=todo_item.estimated_minutes or 30) - - # 기본 이벤트 생성 - event = CalendarEvent( - title=f"{icon} {todo_item.content}", - description=f"Todo ID: {todo_item.id}\nStatus: {todo_item.status}\nEstimated: {todo_item.estimated_minutes or 30}분", - start_time=start_time, - end_time=end_time, - categories=["완료" if todo_item.status == "completed" else "todo"], - color=color, - reminder_minutes=15, - status="CONFIRMED" if todo_item.status == "completed" else "TENTATIVE" - ) - - # 제공자별 커스터마이징 - if provider == CalendarProvider.APPLE: - # 애플 캘린더 특화 - event.color = "#6366f1" # 보라색으로 통일 - event.reminder_minutes = 15 - - elif provider == CalendarProvider.SYNOLOGY: - # 시놀로지 캘린더 특화 - event.location = "Todo-Project" - - elif provider == CalendarProvider.GOOGLE: - # 구글 캘린더 특화 - event.color = "#4285f4" # 구글 블루 - - return event - - @staticmethod - def get_provider_specific_properties(event: CalendarEvent, provider: CalendarProvider) -> Dict[str, Any]: - """ - 제공자별 특화 속성 반환 - - Args: - event: 캘린더 이벤트 - provider: 캘린더 제공자 - - Returns: - Dict: 제공자별 특화 속성 - """ - if provider == CalendarProvider.APPLE: - return { - "X-APPLE-STRUCTURED-LOCATION": event.location, - "X-APPLE-CALENDAR-COLOR": event.color - } - elif provider == CalendarProvider.SYNOLOGY: - return { - "PRIORITY": "5", - "CLASS": "PRIVATE" - } - elif provider == CalendarProvider.GOOGLE: - return { - "colorId": "9", # 파란색 - "visibility": "private" - } - else: - return {} diff --git a/backend/src/integrations/calendar/router.py b/backend/src/integrations/calendar/router.py deleted file mode 100644 index edd89e9..0000000 --- a/backend/src/integrations/calendar/router.py +++ /dev/null @@ -1,363 +0,0 @@ -""" -캘린더 라우터 - 다중 캘린더 제공자 중앙 관리 -- 서비스 클래스 기준: 최대 350줄 -- 간결함 원칙: 단순한 라우팅과 조합 로직만 포함 -""" -import asyncio -from typing import Dict, List, Any, Optional, Union -from enum import Enum -import logging - -from .base import BaseCalendarService, CalendarProvider -from .synology import SynologyCalendarService -from .apple import AppleCalendarService - -logger = logging.getLogger(__name__) - - -class CalendarRouter: - """다중 캘린더 제공자를 관리하는 중앙 라우터""" - - def __init__(self): - self.services: Dict[CalendarProvider, BaseCalendarService] = {} - self.default_provider: Optional[CalendarProvider] = None - - async def register_provider( - self, - provider: CalendarProvider, - credentials: Dict[str, Any], - set_as_default: bool = False - ) -> bool: - """캘린더 제공자 등록 및 인증""" - try: - # 서비스 인스턴스 생성 - service = self._create_service(provider) - if not service: - logger.error(f"지원하지 않는 캘린더 제공자: {provider}") - return False - - # 인증 시도 - if not await service.authenticate(credentials): - logger.error(f"{provider.value} 캘린더 인증 실패") - return False - - # 등록 완료 - self.services[provider] = service - - if set_as_default or not self.default_provider: - self.default_provider = provider - - logger.info(f"{provider.value} 캘린더 제공자 등록 완료") - return True - - except Exception as e: - logger.error(f"캘린더 제공자 등록 중 오류: {e}") - return False - - def _create_service(self, provider: CalendarProvider) -> Optional[BaseCalendarService]: - """제공자별 서비스 인스턴스 생성""" - service_map = { - CalendarProvider.SYNOLOGY: SynologyCalendarService, - CalendarProvider.APPLE: AppleCalendarService, - # 추후 확장: CalendarProvider.GOOGLE: GoogleCalendarService, - } - - service_class = service_map.get(provider) - return service_class() if service_class else None - - async def get_all_calendars(self) -> Dict[str, List[Dict[str, Any]]]: - """모든 등록된 제공자의 캘린더 목록 조회""" - all_calendars = {} - - for provider, service in self.services.items(): - try: - calendars = await service.get_calendars() - all_calendars[provider.value] = calendars - logger.info(f"{provider.value}: {len(calendars)}개 캘린더 발견") - except Exception as e: - logger.error(f"{provider.value} 캘린더 목록 조회 실패: {e}") - all_calendars[provider.value] = [] - - return all_calendars - - async def get_calendars(self, provider: Optional[CalendarProvider] = None) -> List[Dict[str, Any]]: - """특정 제공자 또는 기본 제공자의 캘린더 목록 조회""" - target_provider = provider or self.default_provider - - if not target_provider or target_provider not in self.services: - logger.error(f"캘린더 제공자를 찾을 수 없음: {target_provider}") - return [] - - try: - return await self.services[target_provider].get_calendars() - except Exception as e: - logger.error(f"캘린더 목록 조회 실패: {e}") - return [] - - async def create_event( - self, - calendar_id: str, - event_data: Dict[str, Any], - provider: Optional[CalendarProvider] = None - ) -> Dict[str, Any]: - """이벤트 생성 (특정 제공자 또는 기본 제공자)""" - target_provider = provider or self.default_provider - - if not target_provider or target_provider not in self.services: - logger.error(f"캘린더 제공자를 찾을 수 없음: {target_provider}") - return {} - - try: - result = await self.services[target_provider].create_event(calendar_id, event_data) - result["provider"] = target_provider.value - return result - except Exception as e: - logger.error(f"이벤트 생성 실패: {e}") - return {} - - async def create_event_multi( - self, - calendar_configs: List[Dict[str, Any]], - event_data: Dict[str, Any] - ) -> Dict[str, List[Dict[str, Any]]]: - """ - 여러 캘린더에 동시 이벤트 생성 - calendar_configs: [{"provider": "synology", "calendar_id": "personal"}, ...] - """ - results = {"success": [], "failed": []} - - # 병렬 처리를 위한 태스크 생성 - tasks = [] - for config in calendar_configs: - provider_str = config.get("provider") - calendar_id = config.get("calendar_id") - - try: - provider = CalendarProvider(provider_str) - if provider in self.services: - task = self._create_event_task(provider, calendar_id, event_data, config) - tasks.append(task) - else: - results["failed"].append({ - "provider": provider_str, - "calendar_id": calendar_id, - "error": "제공자를 찾을 수 없음" - }) - except ValueError: - results["failed"].append({ - "provider": provider_str, - "calendar_id": calendar_id, - "error": "지원하지 않는 제공자" - }) - - # 병렬 실행 - if tasks: - task_results = await asyncio.gather(*tasks, return_exceptions=True) - - for i, result in enumerate(task_results): - config = calendar_configs[i] - if isinstance(result, Exception): - results["failed"].append({ - "provider": config.get("provider"), - "calendar_id": config.get("calendar_id"), - "error": str(result) - }) - else: - results["success"].append(result) - - return results - - async def _create_event_task( - self, - provider: CalendarProvider, - calendar_id: str, - event_data: Dict[str, Any], - config: Dict[str, Any] - ) -> Dict[str, Any]: - """이벤트 생성 태스크 (병렬 처리용)""" - try: - result = await self.services[provider].create_event(calendar_id, event_data) - result.update({ - "provider": provider.value, - "calendar_id": calendar_id, - "config": config - }) - return result - except Exception as e: - raise Exception(f"{provider.value} 이벤트 생성 실패: {e}") - - async def update_event( - self, - event_id: str, - event_data: Dict[str, Any], - provider: Optional[CalendarProvider] = None - ) -> Dict[str, Any]: - """이벤트 수정""" - target_provider = provider or self.default_provider - - if not target_provider or target_provider not in self.services: - logger.error(f"캘린더 제공자를 찾을 수 없음: {target_provider}") - return {} - - try: - result = await self.services[target_provider].update_event(event_id, event_data) - result["provider"] = target_provider.value - return result - except Exception as e: - logger.error(f"이벤트 수정 실패: {e}") - return {} - - async def delete_event( - self, - event_id: str, - provider: Optional[CalendarProvider] = None, - **kwargs - ) -> bool: - """이벤트 삭제""" - target_provider = provider or self.default_provider - - if not target_provider or target_provider not in self.services: - logger.error(f"캘린더 제공자를 찾을 수 없음: {target_provider}") - return False - - try: - return await self.services[target_provider].delete_event(event_id, **kwargs) - except Exception as e: - logger.error(f"이벤트 삭제 실패: {e}") - return False - - async def sync_todo_to_calendars( - self, - todo_item, - calendar_configs: Optional[List[Dict[str, Any]]] = None - ) -> Dict[str, Any]: - """Todo 아이템을 여러 캘린더에 동기화""" - if not calendar_configs: - # 기본 제공자만 사용 - if not self.default_provider: - logger.error("기본 캘린더 제공자가 설정되지 않음") - return {"success": [], "failed": []} - - calendar_configs = [{ - "provider": self.default_provider.value, - "calendar_id": "default" - }] - - # Todo를 캘린더 이벤트 형식으로 변환 - event_data = self._format_todo_event(todo_item) - - # 여러 캘린더에 생성 - return await self.create_event_multi(calendar_configs, event_data) - - def _format_todo_event(self, todo_item) -> Dict[str, Any]: - """Todo 아이템을 캘린더 이벤트 형식으로 변환""" - from datetime import datetime, timedelta - import uuid - - # 상태별 태그 설정 - status_tag = "완료" if todo_item.status == "completed" else "todo" - - return { - "uid": str(uuid.uuid4()), - "title": f"📋 {todo_item.content}", - "description": f"Todo 항목\n상태: {status_tag}\n생성일: {todo_item.created_at}", - "start_time": todo_item.start_date or todo_item.created_at, - "end_time": (todo_item.start_date or todo_item.created_at) + timedelta( - minutes=todo_item.estimated_minutes or 30 - ), - "categories": [status_tag, "업무"], - "todo_id": todo_item.id - } - - def get_registered_providers(self) -> List[str]: - """등록된 캘린더 제공자 목록 반환""" - return [provider.value for provider in self.services.keys()] - - def set_default_provider(self, provider: CalendarProvider) -> bool: - """기본 캘린더 제공자 설정""" - if provider in self.services: - self.default_provider = provider - logger.info(f"기본 캘린더 제공자 변경: {provider.value}") - return True - else: - logger.error(f"등록되지 않은 제공자: {provider.value}") - return False - - async def health_check(self) -> Dict[str, Any]: - """모든 등록된 캘린더 서비스 상태 확인""" - health_status = { - "total_providers": len(self.services), - "default_provider": self.default_provider.value if self.default_provider else None, - "providers": {} - } - - for provider, service in self.services.items(): - try: - # 간단한 캘린더 목록 조회로 상태 확인 - calendars = await service.get_calendars() - health_status["providers"][provider.value] = { - "status": "healthy", - "calendar_count": len(calendars) - } - except Exception as e: - health_status["providers"][provider.value] = { - "status": "error", - "error": str(e) - } - - return health_status - - async def close_all(self): - """모든 캘린더 서비스 연결 종료""" - for provider, service in self.services.items(): - try: - if hasattr(service, 'close'): - await service.close() - logger.info(f"{provider.value} 캘린더 서비스 연결 종료") - except Exception as e: - logger.error(f"{provider.value} 연결 종료 중 오류: {e}") - - self.services.clear() - self.default_provider = None - - -# 전역 라우터 인스턴스 (싱글톤 패턴) -_calendar_router: Optional[CalendarRouter] = None - - -def get_calendar_router() -> CalendarRouter: - """캘린더 라우터 싱글톤 인스턴스 반환""" - global _calendar_router - if _calendar_router is None: - _calendar_router = CalendarRouter() - return _calendar_router - - -async def setup_calendar_providers(providers_config: Dict[str, Dict[str, Any]]) -> CalendarRouter: - """ - 캘린더 제공자들을 일괄 설정 - providers_config: { - "synology": {"credentials": {...}, "default": True}, - "apple": {"credentials": {...}, "default": False} - } - """ - router = get_calendar_router() - - for provider_name, config in providers_config.items(): - try: - provider = CalendarProvider(provider_name) - credentials = config.get("credentials", {}) - is_default = config.get("default", False) - - success = await router.register_provider(provider, credentials, is_default) - if success: - logger.info(f"{provider_name} 캘린더 설정 완료") - else: - logger.error(f"{provider_name} 캘린더 설정 실패") - - except ValueError: - logger.error(f"지원하지 않는 캘린더 제공자: {provider_name}") - except Exception as e: - logger.error(f"{provider_name} 캘린더 설정 중 오류: {e}") - - return router diff --git a/backend/src/integrations/calendar/synology.py b/backend/src/integrations/calendar/synology.py deleted file mode 100644 index bf6ace1..0000000 --- a/backend/src/integrations/calendar/synology.py +++ /dev/null @@ -1,401 +0,0 @@ -""" -시놀로지 캘린더 서비스 구현 -""" -import asyncio -import aiohttp -from typing import List, Dict, Any, Optional -from datetime import datetime, timedelta -import caldav -from caldav.lib.error import AuthorizationError, NotFoundError -import logging - -from .base import ( - BaseCalendarService, CalendarProvider, CalendarInfo, CalendarEvent, - CalendarCredentials, CalendarServiceError, AuthenticationError, - CalendarNotFoundError, EventNotFoundError -) - -logger = logging.getLogger(__name__) - - -class SynologyCalendarService(BaseCalendarService): - """시놀로지 캘린더 서비스""" - - def __init__(self, credentials: CalendarCredentials): - super().__init__(credentials) - self.dsm_url = credentials.server_url - self.username = credentials.username - self.password = credentials.password - self.session_token = None - self.caldav_client = None - - # CalDAV URL 구성 - if self.dsm_url and self.username: - self.caldav_url = f"{self.dsm_url}/caldav/{self.username}/" - - async def authenticate(self) -> bool: - """ - 시놀로지 DSM 및 CalDAV 인증 - """ - try: - # 1. DSM API 인증 (선택사항 - 추가 기능용) - await self._authenticate_dsm() - - # 2. CalDAV 인증 (메인) - await self._authenticate_caldav() - - self._authenticated = True - logger.info(f"시놀로지 캘린더 인증 성공: {self.username}") - return True - - except Exception as e: - logger.error(f"시놀로지 캘린더 인증 실패: {e}") - raise AuthenticationError(f"시놀로지 인증 실패: {str(e)}") - - async def _authenticate_dsm(self) -> Optional[str]: - """DSM API 인증 (추가 기능용)""" - if not self.dsm_url: - return None - - login_url = f"{self.dsm_url}/webapi/auth.cgi" - params = { - "api": "SYNO.API.Auth", - "version": "3", - "method": "login", - "account": self.username, - "passwd": self.password, - "session": "TodoProject", - "format": "sid" - } - - try: - async with aiohttp.ClientSession() as session: - async with session.get(login_url, params=params, ssl=False) as response: - data = await response.json() - - if data.get("success"): - self.session_token = data["data"]["sid"] - logger.info("DSM API 인증 성공") - return self.session_token - else: - error_code = data.get("error", {}).get("code", "Unknown") - raise AuthenticationError(f"DSM 로그인 실패 (코드: {error_code})") - - except aiohttp.ClientError as e: - logger.warning(f"DSM API 인증 실패 (CalDAV는 계속 시도): {e}") - return None - - async def _authenticate_caldav(self): - """CalDAV 인증""" - try: - # CalDAV 클라이언트 생성 - self.caldav_client = caldav.DAVClient( - url=self.caldav_url, - username=self.username, - password=self.password - ) - - # 연결 테스트 - principal = self.caldav_client.principal() - calendars = principal.calendars() - - logger.info(f"CalDAV 인증 성공: {len(calendars)}개 캘린더 발견") - - except AuthorizationError as e: - raise AuthenticationError(f"CalDAV 인증 실패: 사용자명 또는 비밀번호가 잘못되었습니다") - except Exception as e: - raise AuthenticationError(f"CalDAV 연결 실패: {str(e)}") - - async def get_calendars(self) -> List[CalendarInfo]: - """캘린더 목록 조회""" - self._ensure_authenticated() - - try: - principal = self.caldav_client.principal() - calendars = principal.calendars() - - calendar_list = [] - for calendar in calendars: - try: - # 캘린더 속성 조회 - props = calendar.get_properties([ - caldav.dav.DisplayName(), - caldav.elements.icalendar.CalendarColor(), - caldav.elements.icalendar.CalendarDescription(), - ]) - - name = props.get(caldav.dav.DisplayName.tag, "Unknown Calendar") - color = props.get(caldav.elements.icalendar.CalendarColor.tag, "#6366f1") - description = props.get(caldav.elements.icalendar.CalendarDescription.tag, "") - - # 색상 형식 정규화 - if color and not color.startswith('#'): - color = f"#{color}" - - calendar_info = CalendarInfo( - id=calendar.url, - name=name, - color=color or "#6366f1", - description=description, - provider=CalendarProvider.SYNOLOGY, - is_writable=True # 시놀로지 캘린더는 일반적으로 쓰기 가능 - ) - - calendar_list.append(calendar_info) - - except Exception as e: - logger.warning(f"캘린더 정보 조회 실패: {e}") - continue - - logger.info(f"시놀로지 캘린더 {len(calendar_list)}개 조회 완료") - return calendar_list - - except Exception as e: - logger.error(f"캘린더 목록 조회 실패: {e}") - raise CalendarServiceError(f"캘린더 목록 조회 실패: {str(e)}") - - async def create_event(self, calendar_id: str, event: CalendarEvent) -> CalendarEvent: - """이벤트 생성""" - self._ensure_authenticated() - - try: - # 캘린더 객체 가져오기 - calendar = self.caldav_client.calendar(url=calendar_id) - - # ICS 형식으로 이벤트 생성 - ics_content = self._event_to_ics(event) - - # 이벤트 추가 - caldav_event = calendar.add_event(ics_content) - - # 생성된 이벤트 ID 설정 - event.id = caldav_event.url - - logger.info(f"시놀로지 캘린더 이벤트 생성 완료: {event.title}") - return event - - except NotFoundError: - raise CalendarNotFoundError(f"캘린더를 찾을 수 없습니다: {calendar_id}") - except Exception as e: - logger.error(f"이벤트 생성 실패: {e}") - raise CalendarServiceError(f"이벤트 생성 실패: {str(e)}") - - async def update_event(self, calendar_id: str, event: CalendarEvent) -> CalendarEvent: - """이벤트 수정""" - self._ensure_authenticated() - - try: - # 기존 이벤트 가져오기 - calendar = self.caldav_client.calendar(url=calendar_id) - caldav_event = calendar.event_by_url(event.id) - - # ICS 형식으로 업데이트 - ics_content = self._event_to_ics(event) - caldav_event.data = ics_content - caldav_event.save() - - logger.info(f"시놀로지 캘린더 이벤트 수정 완료: {event.title}") - return event - - except NotFoundError: - raise EventNotFoundError(f"이벤트를 찾을 수 없습니다: {event.id}") - except Exception as e: - logger.error(f"이벤트 수정 실패: {e}") - raise CalendarServiceError(f"이벤트 수정 실패: {str(e)}") - - async def delete_event(self, calendar_id: str, event_id: str) -> bool: - """이벤트 삭제""" - self._ensure_authenticated() - - try: - calendar = self.caldav_client.calendar(url=calendar_id) - caldav_event = calendar.event_by_url(event_id) - caldav_event.delete() - - logger.info(f"시놀로지 캘린더 이벤트 삭제 완료: {event_id}") - return True - - except NotFoundError: - raise EventNotFoundError(f"이벤트를 찾을 수 없습니다: {event_id}") - except Exception as e: - logger.error(f"이벤트 삭제 실패: {e}") - raise CalendarServiceError(f"이벤트 삭제 실패: {str(e)}") - - async def get_event(self, calendar_id: str, event_id: str) -> CalendarEvent: - """이벤트 조회""" - self._ensure_authenticated() - - try: - calendar = self.caldav_client.calendar(url=calendar_id) - caldav_event = calendar.event_by_url(event_id) - - # ICS에서 CalendarEvent로 변환 - event = self._ics_to_event(caldav_event.data) - event.id = event_id - - return event - - except NotFoundError: - raise EventNotFoundError(f"이벤트를 찾을 수 없습니다: {event_id}") - except Exception as e: - logger.error(f"이벤트 조회 실패: {e}") - raise CalendarServiceError(f"이벤트 조회 실패: {str(e)}") - - def _event_to_ics(self, event: CalendarEvent) -> str: - """CalendarEvent를 ICS 형식으로 변환""" - - # 시간 형식 변환 - start_str = event.start_time.strftime('%Y%m%dT%H%M%S') if event.start_time else "" - end_str = event.end_time.strftime('%Y%m%dT%H%M%S') if event.end_time else "" - - # 카테고리 문자열 생성 - categories_str = ",".join(event.categories) if event.categories else "" - - # 알림 설정 - alarm_str = "" - if event.reminder_minutes: - alarm_str = f"""BEGIN:VALARM -TRIGGER:-PT{event.reminder_minutes}M -ACTION:DISPLAY -DESCRIPTION:Reminder -END:VALARM""" - - ics_content = f"""BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Todo-Project//Synology Calendar//EN -BEGIN:VEVENT -UID:{event.id} -DTSTART:{start_str} -DTEND:{end_str} -SUMMARY:{event.title} -DESCRIPTION:{event.description or ''} -CATEGORIES:{categories_str} -STATUS:{event.status} -PRIORITY:5 -CLASS:PRIVATE -{alarm_str} -END:VEVENT -END:VCALENDAR""" - - return ics_content - - def _ics_to_event(self, ics_content: str) -> CalendarEvent: - """ICS 형식을 CalendarEvent로 변환""" - # 간단한 ICS 파싱 (실제로는 icalendar 라이브러리 사용 권장) - lines = ics_content.split('\n') - - event = CalendarEvent() - - for line in lines: - line = line.strip() - if line.startswith('SUMMARY:'): - event.title = line[8:] - elif line.startswith('DESCRIPTION:'): - event.description = line[12:] - elif line.startswith('DTSTART:'): - try: - event.start_time = datetime.strptime(line[8:], '%Y%m%dT%H%M%S') - except ValueError: - pass - elif line.startswith('DTEND:'): - try: - event.end_time = datetime.strptime(line[6:], '%Y%m%dT%H%M%S') - except ValueError: - pass - elif line.startswith('CATEGORIES:'): - categories = line[11:].split(',') - event.categories = [cat.strip() for cat in categories if cat.strip()] - elif line.startswith('STATUS:'): - event.status = line[7:] - - return event - - async def get_calendar_by_name(self, name: str) -> Optional[CalendarInfo]: - """이름으로 캘린더 찾기""" - calendars = await self.get_calendars() - for calendar in calendars: - if calendar.name.lower() == name.lower(): - return calendar - return None - - async def create_todo_calendar_if_not_exists(self) -> CalendarInfo: - """Todo 전용 캘린더가 없으면 생성""" - # 기존 Todo 캘린더 찾기 - todo_calendar = await self.get_calendar_by_name("Todo") - - if todo_calendar: - return todo_calendar - - # Todo 캘린더 생성 (시놀로지에서는 웹 인터페이스를 통해 생성해야 함) - # 여기서는 기본 캘린더를 사용하거나 사용자에게 안내 - calendars = await self.get_calendars() - if calendars: - logger.info("Todo 전용 캘린더가 없어 첫 번째 캘린더를 사용합니다.") - return calendars[0] - - raise CalendarServiceError("사용 가능한 캘린더가 없습니다. 시놀로지에서 캘린더를 먼저 생성해주세요.") - - -class SynologyMailService: - """시놀로지 MailPlus 서비스 (캘린더 연동용)""" - - def __init__(self, smtp_server: str, smtp_port: int, username: str, password: str): - self.smtp_server = smtp_server - self.smtp_port = smtp_port - self.username = username - self.password = password - - async def send_calendar_invitation(self, to_email: str, event: CalendarEvent, ics_content: str): - """캘린더 초대 메일 발송""" - import smtplib - from email.mime.multipart import MIMEMultipart - from email.mime.text import MIMEText - from email.mime.base import MIMEBase - from email import encoders - - try: - msg = MIMEMultipart() - msg['From'] = self.username - msg['To'] = to_email - msg['Subject'] = f"📋 할일 일정: {event.title}" - - # 메일 본문 - body = f""" -새로운 할일이 일정에 추가되었습니다. - -제목: {event.title} -시작: {event.start_time.strftime('%Y-%m-%d %H:%M') if event.start_time else '미정'} -종료: {event.end_time.strftime('%Y-%m-%d %H:%M') if event.end_time else '미정'} -설명: {event.description or ''} - -이 메일의 첨부파일을 캘린더 앱에서 열면 일정이 자동으로 추가됩니다. - --- Todo-Project - """ - - msg.attach(MIMEText(body, 'plain', 'utf-8')) - - # ICS 파일 첨부 - if ics_content: - part = MIMEBase('text', 'calendar') - part.set_payload(ics_content.encode('utf-8')) - encoders.encode_base64(part) - part.add_header( - 'Content-Disposition', - 'attachment; filename="todo_event.ics"' - ) - part.add_header('Content-Type', 'text/calendar; charset=utf-8') - msg.attach(part) - - # SMTP 발송 - server = smtplib.SMTP(self.smtp_server, self.smtp_port) - server.starttls() - server.login(self.username, self.password) - server.send_message(msg) - server.quit() - - logger.info(f"캘린더 초대 메일 발송 완료: {to_email}") - - except Exception as e: - logger.error(f"메일 발송 실패: {e}") - raise CalendarServiceError(f"메일 발송 실패: {str(e)}") diff --git a/backend/src/main.py b/backend/src/main.py index 8ad53a5..7fbfa33 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -6,7 +6,9 @@ from fastapi import FastAPI, Request, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse +from fastapi.staticfiles import StaticFiles import logging +import os from .core.config import settings from .api.routes import auth, todos, calendar @@ -46,15 +48,35 @@ app.add_middleware( async def validation_exception_handler(request: Request, exc: RequestValidationError): logger.error(f"Validation 오류 - URL: {request.url}") logger.error(f"Validation 오류 상세: {exc.errors()}") + + # JSON 직렬화 가능한 형태로 에러 변환 + serializable_errors = [] + for error in exc.errors(): + serializable_error = {} + for key, value in error.items(): + if isinstance(value, bytes): + serializable_error[key] = value.decode('utf-8') + else: + serializable_error[key] = str(value) if not isinstance(value, (str, int, float, bool, list, dict, type(None))) else value + serializable_errors.append(serializable_error) + return JSONResponse( status_code=422, content={ "detail": "요청 데이터 검증 실패", - "errors": exc.errors() + "errors": serializable_errors } ) +# 정적 파일 서빙 (업로드된 이미지) +UPLOAD_DIR = "/app/uploads" +if not os.path.exists(UPLOAD_DIR): + os.makedirs(UPLOAD_DIR) +app.mount("/uploads", StaticFiles(directory=UPLOAD_DIR), name="uploads") + # 라우터 등록 +from .api.routes import setup +app.include_router(setup.router, prefix="/api/setup", tags=["setup"]) app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(todos.router, prefix="/api", tags=["todos"]) app.include_router(calendar.router, prefix="/api", tags=["calendar"]) @@ -87,6 +109,47 @@ async def create_sample_data(): return +async def wait_for_database(): + """데이터베이스 연결 대기""" + import asyncio + import asyncpg + from urllib.parse import urlparse + + # DATABASE_URL 파싱 + parsed_url = urlparse(settings.DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://")) + + max_retries = 30 # 최대 30번 시도 (30초) + retry_count = 0 + + while retry_count < max_retries: + try: + logger.info(f"🔄 데이터베이스 연결 시도 {retry_count + 1}/{max_retries}") + + # asyncpg로 직접 연결 테스트 + conn = await asyncpg.connect( + host=parsed_url.hostname, + port=parsed_url.port or 5432, + user=parsed_url.username, + password=parsed_url.password, + database=parsed_url.path.lstrip('/'), + timeout=5 + ) + await conn.close() + + logger.info("✅ 데이터베이스 연결 성공!") + return True + + except Exception as e: + retry_count += 1 + logger.warning(f"❌ 데이터베이스 연결 실패 ({retry_count}/{max_retries}): {e}") + + if retry_count < max_retries: + await asyncio.sleep(1) + else: + logger.error("💥 데이터베이스 연결 최대 재시도 횟수 초과") + raise Exception("데이터베이스에 연결할 수 없습니다.") + + @app.on_event("startup") async def startup_event(): """애플리케이션 시작 시 초기화""" @@ -94,6 +157,9 @@ async def startup_event(): logger.info(f"📊 환경: {settings.ENVIRONMENT}") logger.info(f"🔗 데이터베이스: {settings.DATABASE_URL}") + # 데이터베이스 연결 대기 + await wait_for_database() + # 데이터베이스 초기화 from .core.database import init_db await init_db() diff --git a/backend/src/models/todo.py b/backend/src/models/todo.py index 3fc6c98..398cb33 100644 --- a/backend/src/models/todo.py +++ b/backend/src/models/todo.py @@ -13,9 +13,9 @@ from ..core.database import Base class TodoCategory(str, enum.Enum): """Todo 카테고리""" - TODO = "todo" - CALENDAR = "calendar" - CHECKLIST = "checklist" + MEMO = "memo" # 수신함의 메모 (upload.html에서 생성) + TODO = "todo" # Todo 목록 (inbox.html에서 변환된 것) + BOARD = "board" # 보드 (프로젝트 관리) class TodoStatus(str, enum.Enum): @@ -35,20 +35,23 @@ class Todo(Base): user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) # 기본 정보 - title = Column(String(200), nullable=False) # 할일 제목 - description = Column(Text, nullable=True) # 할일 설명 - category = Column(Enum(TodoCategory), nullable=True, default=None) + title = Column(String(200), nullable=True) # 제목 (메모의 경우 선택사항) + description = Column(Text, nullable=False) # 내용 (메모/Todo 모두 필수) + category = Column(Enum(TodoCategory), nullable=False, default=TodoCategory.MEMO) status = Column(Enum(TodoStatus), nullable=False, default=TodoStatus.PENDING) # 시간 관리 created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow) updated_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) - due_date = Column(DateTime(timezone=True), nullable=True) # 마감일 + start_date = Column(DateTime(timezone=True), nullable=True) # Todo 시작일 (category=todo일 때만 사용) completed_at = Column(DateTime(timezone=True), nullable=True) # 추가 정보 image_urls = Column(Text, nullable=True) # 첨부 이미지 URLs (JSON 배열 형태, 최대 5개) - tags = Column(String(500), nullable=True) # 태그 (쉼표로 구분) + + # 보드 관련 (category=board일 때 사용) + board_id = Column(UUID(as_uuid=True), nullable=True) # 보드 ID (첫 번째 메모가 보드 생성, 나머지는 하위 메모) + is_board_header = Column(Boolean, default=False) # 보드의 헤더(제목) 여부 # 관계 user = relationship("User", back_populates="todos") diff --git a/backend/src/schemas/todo.py b/backend/src/schemas/todo.py index 0ddf249..dbc5b1b 100644 --- a/backend/src/schemas/todo.py +++ b/backend/src/schemas/todo.py @@ -9,9 +9,9 @@ from enum import Enum class TodoCategoryEnum(str, Enum): - TODO = "todo" - CALENDAR = "calendar" - CHECKLIST = "checklist" + MEMO = "memo" # 수신함의 메모 + TODO = "todo" # Todo 목록 + BOARD = "board" # 보드 (프로젝트 관리) class TodoStatusEnum(str, Enum): @@ -23,59 +23,48 @@ class TodoStatusEnum(str, Enum): class TodoBase(BaseModel): - title: str = Field(..., min_length=1, max_length=200) - description: Optional[str] = Field(None, max_length=2000) - category: Optional[TodoCategoryEnum] = None + title: Optional[str] = Field(None, max_length=200) # 메모의 경우 선택사항 + description: str = Field(..., min_length=1, max_length=2000) # 내용은 필수 + category: TodoCategoryEnum = Field(default=TodoCategoryEnum.MEMO) class TodoCreate(TodoBase): - """Todo 생성""" - due_date: Optional[str] = None # 문자열로 변경 (한국 시간 형식) + """Todo/메모 생성""" + start_date: Optional[str] = None # Todo 시작일 (category=todo일 때만 사용) image_urls: Optional[List[str]] = Field(None, max_items=5, description="최대 5개의 이미지 URL") - tags: Optional[str] = Field(None, max_length=500) + board_id: Optional[str] = None # 보드 ID (보드 하위 메모일 때) + is_board_header: Optional[bool] = False # 보드 헤더 여부 class TodoUpdate(BaseModel): - """Todo 수정""" - title: Optional[str] = Field(None, min_length=1, max_length=200) - description: Optional[str] = Field(None, max_length=2000) + """Todo/메모 수정""" + title: Optional[str] = Field(None, max_length=200) + description: Optional[str] = Field(None, min_length=1, max_length=2000) category: Optional[TodoCategoryEnum] = None status: Optional[TodoStatusEnum] = None - due_date: Optional[str] = None # 문자열로 변경 (한국 시간 형식) + start_date: Optional[str] = None # Todo 시작일 image_urls: Optional[List[str]] = Field(None, max_items=5, description="최대 5개의 이미지 URL") - tags: Optional[str] = Field(None, max_length=500) + board_id: Optional[str] = None # 보드 ID + is_board_header: Optional[bool] = None # 보드 헤더 여부 class TodoResponse(BaseModel): id: UUID user_id: UUID - title: str - description: Optional[str] = None - category: Optional[TodoCategoryEnum] = None + title: Optional[str] = None + description: str + category: TodoCategoryEnum status: TodoStatusEnum created_at: datetime updated_at: datetime - due_date: Optional[str] = None # 문자열로 변경 (한국 시간 형식) + start_date: Optional[str] = None # Todo 시작일 completed_at: Optional[datetime] = None image_urls: Optional[List[str]] = None - tags: Optional[str] = None + board_id: Optional[str] = None # 보드 ID + is_board_header: Optional[bool] = None # 보드 헤더 여부 class Config: from_attributes = True -class TodoStats(BaseModel): - """Todo 통계""" - total_count: int - pending_count: int - in_progress_count: int - completed_count: int - completion_rate: float # 완료율 (%) - - -class TodoDashboard(BaseModel): - """Todo 대시보드""" - stats: TodoStats - today_todos: List[TodoResponse] - overdue_todos: List[TodoResponse] - upcoming_todos: List[TodoResponse] +# 간단한 워크플로우에 맞게 통계 기능 제거 diff --git a/backend/src/services/__init__.py b/backend/src/services/__init__.py index 386b2f6..c851509 100644 --- a/backend/src/services/__init__.py +++ b/backend/src/services/__init__.py @@ -2,11 +2,4 @@ 서비스 레이어 모듈 """ -from .todo_service import TodoService -from .calendar_sync_service import CalendarSyncService, get_calendar_sync_service - -__all__ = [ - "TodoService", - "CalendarSyncService", - "get_calendar_sync_service" -] +__all__ = [] diff --git a/backend/src/services/calendar_sync_service.py b/backend/src/services/calendar_sync_service.py deleted file mode 100644 index e4efdd3..0000000 --- a/backend/src/services/calendar_sync_service.py +++ /dev/null @@ -1,74 +0,0 @@ -""" -캘린더 동기화 서비스 -- 서비스 클래스 기준: 최대 350줄 -- 간결함 원칙: Todo ↔ 캘린더 동기화만 담당 -""" -import logging -from typing import Dict, Any, Optional -from ..models.todo import TodoItem -from ..integrations.calendar import get_calendar_router - -logger = logging.getLogger(__name__) - - -class CalendarSyncService: - """Todo와 캘린더 간 동기화 서비스""" - - def __init__(self): - self.calendar_router = get_calendar_router() - - async def sync_todo_create(self, todo_item: TodoItem) -> Dict[str, Any]: - """새 할일을 캘린더에 생성""" - try: - result = await self.calendar_router.sync_todo_to_calendars(todo_item) - logger.info(f"할일 {todo_item.id} 캘린더 생성 완료") - return {"success": True, "result": result} - - except Exception as e: - logger.error(f"할일 {todo_item.id} 캘린더 생성 실패: {e}") - return {"success": False, "error": str(e)} - - async def sync_todo_complete(self, todo_item: TodoItem) -> Dict[str, Any]: - """완료된 할일의 캘린더 태그 업데이트 (todo → 완료)""" - try: - # 향후 구현: 기존 이벤트를 찾아서 태그 업데이트 - logger.info(f"할일 {todo_item.id} 완료 상태로 캘린더 업데이트") - return {"success": True, "action": "completed"} - - except Exception as e: - logger.error(f"할일 {todo_item.id} 완료 상태 캘린더 업데이트 실패: {e}") - return {"success": False, "error": str(e)} - - async def sync_todo_delay(self, todo_item: TodoItem) -> Dict[str, Any]: - """지연된 할일의 캘린더 날짜 수정""" - try: - # 향후 구현: 기존 이벤트를 찾아서 날짜 수정 - logger.info(f"할일 {todo_item.id} 지연 날짜로 캘린더 업데이트") - return {"success": True, "action": "delayed"} - - except Exception as e: - logger.error(f"할일 {todo_item.id} 지연 날짜 캘린더 업데이트 실패: {e}") - return {"success": False, "error": str(e)} - - async def sync_todo_delete(self, todo_item: TodoItem) -> Dict[str, Any]: - """삭제된 할일의 캘린더 이벤트 제거""" - try: - # 향후 구현: 기존 이벤트를 찾아서 삭제 - logger.info(f"할일 {todo_item.id} 캘린더 이벤트 삭제") - return {"success": True, "action": "deleted"} - - except Exception as e: - logger.error(f"할일 {todo_item.id} 캘린더 이벤트 삭제 실패: {e}") - return {"success": False, "error": str(e)} - - -# 전역 인스턴스 -_calendar_sync_service: Optional[CalendarSyncService] = None - - -def get_calendar_sync_service() -> CalendarSyncService: - """캘린더 동기화 서비스 싱글톤 인스턴스""" - global _calendar_sync_service - if _calendar_sync_service is None: - _calendar_sync_service = CalendarSyncService() - return _calendar_sync_service diff --git a/backend/src/services/file_service.py b/backend/src/services/file_service.py index 487adf9..bc3b470 100644 --- a/backend/src/services/file_service.py +++ b/backend/src/services/file_service.py @@ -13,6 +13,47 @@ def ensure_upload_dir(): if not os.path.exists(UPLOAD_DIR): os.makedirs(UPLOAD_DIR) +def save_image(image_data: bytes, filename: str = None) -> Optional[str]: + """이미지 데이터를 파일로 저장하고 경로 반환""" + try: + ensure_upload_dir() + + # 파일명 생성 + if not filename: + filename = f"{uuid.uuid4()}.jpg" + else: + # 확장자 확인 및 변경 + name, ext = os.path.splitext(filename) + filename = f"{name}_{uuid.uuid4()}.jpg" + + filepath = os.path.join(UPLOAD_DIR, filename) + + # 이미지 처리 및 저장 + image = Image.open(io.BytesIO(image_data)) + + # RGBA를 RGB로 변환 (JPEG는 투명도 지원 안함) + if image.mode in ('RGBA', 'LA'): + background = Image.new('RGB', image.size, (255, 255, 255)) + background.paste(image, mask=image.split()[-1] if image.mode == 'RGBA' else None) + image = background + elif image.mode != 'RGB': + image = image.convert('RGB') + + # 이미지 크기 조정 (최대 1920px) + max_size = 1920 + if image.width > max_size or image.height > max_size: + image.thumbnail((max_size, max_size), Image.Resampling.LANCZOS) + + # JPEG로 저장 + image.save(filepath, 'JPEG', quality=85, optimize=True) + + # 웹 경로 반환 + return f"/uploads/{filename}" + + except Exception as e: + print(f"이미지 저장 실패: {e}") + return None + def save_base64_image(base64_string: str) -> Optional[str]: """Base64 이미지를 파일로 저장하고 경로 반환""" try: diff --git a/backend/src/services/todo_service.py b/backend/src/services/todo_service.py deleted file mode 100644 index 72ffc5c..0000000 --- a/backend/src/services/todo_service.py +++ /dev/null @@ -1,300 +0,0 @@ -""" -Todo 비즈니스 로직 서비스 -- 서비스 클래스 기준: 최대 350줄 -- 간결함 원칙: 핵심 Todo 로직만 포함 -""" -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, and_ -from typing import List, Optional -from datetime import datetime -from uuid import UUID -import logging - -from ..models.user import User -from ..models.todo import TodoItem, TodoComment -from ..schemas.todo import ( - TodoItemCreate, TodoItemSchedule, TodoItemSplit, TodoItemDelay, - TodoItemResponse, TodoCommentCreate, TodoCommentResponse -) - -logger = logging.getLogger(__name__) - - -class TodoService: - """Todo 관련 비즈니스 로직 서비스""" - - def __init__(self, db: AsyncSession): - self.db = db - - async def create_todo(self, todo_data: TodoItemCreate, user_id: UUID) -> TodoItemResponse: - """새 할일 생성""" - new_todo = TodoItem( - user_id=user_id, - content=todo_data.content, - status="draft" - ) - - self.db.add(new_todo) - await self.db.commit() - await self.db.refresh(new_todo) - - return await self._build_todo_response(new_todo) - - async def schedule_todo( - self, - todo_id: UUID, - schedule_data: TodoItemSchedule, - user_id: UUID - ) -> TodoItemResponse: - """할일 일정 설정""" - todo_item = await self._get_user_todo(todo_id, user_id, "draft") - - # 2시간 이상인 경우 분할 제안 - if schedule_data.estimated_minutes > 120: - raise ValueError("Tasks longer than 2 hours should be split") - - todo_item.start_date = schedule_data.start_date - todo_item.estimated_minutes = schedule_data.estimated_minutes - todo_item.status = "scheduled" - - await self.db.commit() - await self.db.refresh(todo_item) - - return await self._build_todo_response(todo_item) - - async def complete_todo(self, todo_id: UUID, user_id: UUID) -> TodoItemResponse: - """할일 완료""" - todo_item = await self._get_user_todo(todo_id, user_id, "active") - - todo_item.status = "completed" - todo_item.completed_at = datetime.utcnow() - - await self.db.commit() - await self.db.refresh(todo_item) - - return await self._build_todo_response(todo_item) - - async def delay_todo( - self, - todo_id: UUID, - delay_data: TodoItemDelay, - user_id: UUID - ) -> TodoItemResponse: - """할일 지연""" - todo_item = await self._get_user_todo(todo_id, user_id, "active") - - todo_item.status = "delayed" - todo_item.delayed_until = delay_data.delayed_until - todo_item.start_date = delay_data.delayed_until - - await self.db.commit() - await self.db.refresh(todo_item) - - return await self._build_todo_response(todo_item) - - async def split_todo( - self, - todo_id: UUID, - split_data: TodoItemSplit, - user_id: UUID - ) -> List[TodoItemResponse]: - """할일 분할""" - original_todo = await self._get_user_todo(todo_id, user_id, "draft") - - # 분할된 할일들 생성 - subtasks = [] - for i, (content, minutes) in enumerate(zip(split_data.subtasks, split_data.estimated_minutes_per_task)): - if minutes > 120: - raise ValueError(f"Subtask {i+1} is longer than 2 hours") - - subtask = TodoItem( - user_id=user_id, - content=content, - status="draft", - parent_id=original_todo.id, - split_order=i + 1 - ) - self.db.add(subtask) - subtasks.append(subtask) - - original_todo.status = "split" - await self.db.commit() - - # 응답 데이터 구성 - response_data = [] - for subtask in subtasks: - await self.db.refresh(subtask) - response_data.append(await self._build_todo_response(subtask)) - - return response_data - - async def get_todos( - self, - user_id: UUID, - status_filter: Optional[str] = None - ) -> List[TodoItemResponse]: - """할일 목록 조회""" - query = select(TodoItem).where(TodoItem.user_id == user_id) - - if status_filter: - query = query.where(TodoItem.status == status_filter) - - query = query.order_by(TodoItem.created_at.desc()) - - result = await self.db.execute(query) - todo_items = result.scalars().all() - - response_data = [] - for todo_item in todo_items: - response_data.append(await self._build_todo_response(todo_item)) - - return response_data - - async def get_active_todos(self, user_id: UUID) -> List[TodoItemResponse]: - """활성 할일 조회 (scheduled → active 자동 변환 포함)""" - now = datetime.utcnow() - - # scheduled → active 자동 변환 - update_result = await self.db.execute( - select(TodoItem).where( - and_( - TodoItem.user_id == user_id, - TodoItem.status == "scheduled", - TodoItem.start_date <= now - ) - ) - ) - scheduled_items = update_result.scalars().all() - - for item in scheduled_items: - item.status = "active" - - if scheduled_items: - await self.db.commit() - - # active 할일들 조회 - result = await self.db.execute( - select(TodoItem).where( - and_( - TodoItem.user_id == user_id, - TodoItem.status == "active" - ) - ).order_by(TodoItem.start_date.asc()) - ) - active_todos = result.scalars().all() - - response_data = [] - for todo_item in active_todos: - response_data.append(await self._build_todo_response(todo_item)) - - return response_data - - async def create_comment( - self, - todo_id: UUID, - comment_data: TodoCommentCreate, - user_id: UUID - ) -> TodoCommentResponse: - """댓글 생성""" - # 할일 존재 확인 - await self._get_user_todo(todo_id, user_id) - - new_comment = TodoComment( - todo_item_id=todo_id, - user_id=user_id, - content=comment_data.content - ) - - self.db.add(new_comment) - await self.db.commit() - await self.db.refresh(new_comment) - - return TodoCommentResponse( - id=new_comment.id, - todo_item_id=new_comment.todo_item_id, - user_id=new_comment.user_id, - content=new_comment.content, - created_at=new_comment.created_at, - updated_at=new_comment.updated_at - ) - - async def get_comments(self, todo_id: UUID, user_id: UUID) -> List[TodoCommentResponse]: - """댓글 목록 조회""" - # 할일 존재 확인 - await self._get_user_todo(todo_id, user_id) - - result = await self.db.execute( - select(TodoComment).where(TodoComment.todo_item_id == todo_id) - .order_by(TodoComment.created_at.asc()) - ) - comments = result.scalars().all() - - return [ - TodoCommentResponse( - id=comment.id, - todo_item_id=comment.todo_item_id, - user_id=comment.user_id, - content=comment.content, - created_at=comment.created_at, - updated_at=comment.updated_at - ) - for comment in comments - ] - - # ======================================================================== - # 헬퍼 메서드들 - # ======================================================================== - - async def _get_user_todo( - self, - todo_id: UUID, - user_id: UUID, - required_status: Optional[str] = None - ) -> TodoItem: - """사용자의 할일 조회""" - query = select(TodoItem).where( - and_( - TodoItem.id == todo_id, - TodoItem.user_id == user_id - ) - ) - - if required_status: - query = query.where(TodoItem.status == required_status) - - result = await self.db.execute(query) - todo_item = result.scalar_one_or_none() - - if not todo_item: - detail = "Todo item not found" - if required_status: - detail += f" or not in {required_status} status" - raise ValueError(detail) - - return todo_item - - async def _get_comment_count(self, todo_id: UUID) -> int: - """댓글 수 조회""" - result = await self.db.execute( - select(func.count(TodoComment.id)).where(TodoComment.todo_item_id == todo_id) - ) - return result.scalar() or 0 - - async def _build_todo_response(self, todo_item: TodoItem) -> TodoItemResponse: - """TodoItem을 TodoItemResponse로 변환""" - comment_count = await self._get_comment_count(todo_item.id) - - return TodoItemResponse( - id=todo_item.id, - user_id=todo_item.user_id, - content=todo_item.content, - status=todo_item.status, - created_at=todo_item.created_at, - start_date=todo_item.start_date, - estimated_minutes=todo_item.estimated_minutes, - completed_at=todo_item.completed_at, - delayed_until=todo_item.delayed_until, - parent_id=todo_item.parent_id, - split_order=todo_item.split_order, - comment_count=comment_count - ) diff --git a/deploy-synology.sh b/deploy-synology.sh new file mode 100755 index 0000000..681b6a1 --- /dev/null +++ b/deploy-synology.sh @@ -0,0 +1,179 @@ +#!/bin/bash + +# ============================================================================= +# Todo-Project 시놀로지 배포 스크립트 +# ============================================================================= + +set -e # 오류 발생 시 스크립트 중단 + +# 색상 정의 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# 로그 함수 +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# 시놀로지 환경 확인 +check_synology_environment() { + log_info "시놀로지 환경 확인 중..." + + # Docker 설치 확인 + if ! command -v docker &> /dev/null; then + log_error "Docker가 설치되지 않았습니다. Container Manager 또는 Docker 패키지를 설치하세요." + exit 1 + fi + + # Docker Compose 확인 + if ! command -v docker-compose &> /dev/null; then + log_error "Docker Compose가 설치되지 않았습니다." + exit 1 + fi + + log_success "Docker 환경 확인 완료" +} + +# 디렉토리 생성 +create_directories() { + log_info "시놀로지 디렉토리 구조 생성 중..." + + # 이미지 업로드 저장소 (volume1) + sudo mkdir -p /volume1/todo-project/uploads + sudo chmod 755 /volume1/todo-project/uploads + sudo chown -R 1000:1000 /volume1/todo-project + + # 설정 및 데이터베이스 저장소 (volume3) + sudo mkdir -p /volume3/docker/todo-project/config/migrations + sudo mkdir -p /volume3/docker/todo-project/postgres + sudo chown -R 999:999 /volume3/docker/todo-project/postgres + sudo chown -R 1000:1000 /volume3/docker/todo-project/config + + log_success "디렉토리 구조 생성 완료" +} + +# 환경 파일 설정 +setup_environment() { + log_info "환경 파일 설정 중..." + + if [ ! -f .env ]; then + if [ -f env.synology.example ]; then + cp env.synology.example .env + log_warning ".env 파일이 생성되었습니다. 필수 설정을 수정하세요:" + log_warning "- SECRET_KEY" + log_warning "- POSTGRES_PASSWORD" + log_warning "- CORS_ORIGINS (시놀로지 IP 추가)" + echo + read -p "환경 설정을 완료했습니까? (y/N): " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + log_error "환경 설정을 완료한 후 다시 실행하세요." + exit 1 + fi + else + log_error "env.synology.example 파일이 없습니다." + exit 1 + fi + else + log_success "기존 .env 파일을 사용합니다." + fi +} + +# 마이그레이션 파일 복사 +copy_migrations() { + log_info "데이터베이스 마이그레이션 파일 복사 중..." + + if [ -d backend/migrations ]; then + sudo cp -r backend/migrations/* /volume3/docker/todo-project/config/migrations/ + log_success "마이그레이션 파일 복사 완료" + else + log_warning "마이그레이션 파일이 없습니다." + fi +} + +# Docker 이미지 빌드 및 배포 +deploy_application() { + log_info "Todo-Project 배포 중..." + + # 기존 컨테이너 중지 및 제거 + if docker-compose ps -q | grep -q .; then + log_info "기존 컨테이너 중지 중..." + docker-compose down + fi + + # 이미지 빌드 및 컨테이너 시작 + log_info "Docker 이미지 빌드 및 컨테이너 시작 중..." + docker-compose up -d --build + + # 컨테이너 시작 대기 + log_info "컨테이너 시작 대기 중..." + sleep 30 + + # 헬스체크 + log_info "서비스 상태 확인 중..." + if curl -f http://localhost:9000/health &> /dev/null; then + log_success "백엔드 서비스 정상 작동" + else + log_error "백엔드 서비스 오류" + docker-compose logs backend + exit 1 + fi +} + +# 배포 완료 안내 +show_completion_info() { + log_success "🎉 Todo-Project 시놀로지 배포 완료!" + echo + echo "=== 접속 정보 ===" + echo "웹 인터페이스: http://$(hostname -I | awk '{print $1}'):${FRONTEND_PORT:-4000}" + echo "API 서버: http://$(hostname -I | awk '{print $1}'):${BACKEND_PORT:-9000}" + echo + echo "=== 다음 단계 ===" + echo "1. 웹 브라우저에서 접속하여 관리자 계정을 생성하세요" + echo "2. 리버스 프록시 설정 (선택사항)" + echo "3. 방화벽 설정 확인" + echo + echo "=== 유용한 명령어 ===" + echo "로그 확인: docker-compose logs -f" + echo "컨테이너 상태: docker-compose ps" + echo "서비스 재시작: docker-compose restart" + echo "완전 재배포: docker-compose down && docker-compose up -d --build" +} + +# 메인 실행 함수 +main() { + echo "=== Todo-Project 시놀로지 배포 스크립트 ===" + echo + + # 루트 권한 확인 + if [ "$EUID" -ne 0 ]; then + log_error "이 스크립트는 root 권한이 필요합니다. sudo로 실행하세요." + exit 1 + fi + + # 단계별 실행 + check_synology_environment + create_directories + setup_environment + copy_migrations + deploy_application + show_completion_info +} + +# 스크립트 실행 +main "$@" diff --git a/docker-compose.yml b/docker-compose.yml index c742e2e..05b21dd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,56 +1,84 @@ -version: '3.8' - services: frontend: build: context: ./frontend dockerfile: Dockerfile ports: - - "4000:80" + - "${FRONTEND_PORT:-4000}:80" depends_on: - backend - environment: - - API_BASE_URL=http://localhost:9000/api - volumes: - - ./frontend/static:/usr/share/nginx/html/static restart: unless-stopped + networks: + - todo-network backend: build: context: ./backend dockerfile: Dockerfile ports: - - "9000:9000" + - "${BACKEND_PORT:-9000}:9000" depends_on: - - database + database: + condition: service_healthy environment: - DATABASE_URL=postgresql+asyncpg://todo_user:${POSTGRES_PASSWORD:-todo_password}@database:5432/todo_db - SECRET_KEY=${SECRET_KEY:-your-secret-key-change-this-in-production} - - DEBUG=${DEBUG:-true} - - CORS_ORIGINS=["http://localhost:4000", "http://127.0.0.1:4000"] - - SYNOLOGY_DSM_URL=${SYNOLOGY_DSM_URL:-} - - SYNOLOGY_USERNAME=${SYNOLOGY_USERNAME:-} - - SYNOLOGY_PASSWORD=${SYNOLOGY_PASSWORD:-} - - ENABLE_SYNOLOGY_INTEGRATION=${ENABLE_SYNOLOGY_INTEGRATION:-false} + - DEBUG=${DEBUG:-false} + - CORS_ORIGINS=${CORS_ORIGINS:-["http://localhost:4000", "http://127.0.0.1:4000"]} + # Synology MailPlus 통합 설정 (선택사항) + - SYNOLOGY_MAIL_SERVER=${SYNOLOGY_MAIL_SERVER:-} + - SYNOLOGY_MAIL_USERNAME=${SYNOLOGY_MAIL_USERNAME:-} + - SYNOLOGY_MAIL_PASSWORD=${SYNOLOGY_MAIL_PASSWORD:-} + - ENABLE_MAIL_MONITORING=${ENABLE_MAIL_MONITORING:-false} + - MAIL_CHECK_INTERVAL=${MAIL_CHECK_INTERVAL:-300} + - TODO_KEYWORDS=${TODO_KEYWORDS:-todo,할일,task} + - ATTACHMENTS_PATH=/data/uploads volumes: + # 시놀로지 볼륨 매핑 + - ${SYNOLOGY_UPLOADS_PATH:-/volume1/todo-project/uploads}:/data/uploads + - ${SYNOLOGY_CONFIG_PATH:-/volume3/docker/todo-project/config}:/app/config + # 로컬 개발용 (시놀로지에서는 제거) - ./backend/src:/app/src - - ./backend/uploads:/app/uploads - - todo_uploads:/data/uploads restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + networks: + - todo-network database: image: postgres:15-alpine ports: - - "5434:5432" + - "${DATABASE_PORT:-5432}:5432" environment: - POSTGRES_USER=${POSTGRES_USER:-todo_user} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-todo_password} - POSTGRES_DB=${POSTGRES_DB:-todo_db} volumes: + # 시놀로지 볼륨 매핑 + - ${SYNOLOGY_DB_PATH:-/volume3/docker/todo-project/postgres}:/var/lib/postgresql/data + - ${SYNOLOGY_CONFIG_PATH:-/volume3/docker/todo-project/config}/migrations:/docker-entrypoint-initdb.d + # 로컬 개발용 (시놀로지에서는 제거) - postgres_data:/var/lib/postgresql/data - - ./database/init:/docker-entrypoint-initdb.d + - ./backend/migrations:/docker-entrypoint-initdb.d restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-todo_user} -d ${POSTGRES_DB:-todo_db}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + networks: + - todo-network +networks: + todo-network: + driver: bridge + +# 로컬 개발용 볼륨 (시놀로지에서는 사용하지 않음) volumes: postgres_data: - todo_uploads: + todo_uploads: \ No newline at end of file diff --git a/env.synology.example b/env.synology.example new file mode 100644 index 0000000..55e2309 --- /dev/null +++ b/env.synology.example @@ -0,0 +1,46 @@ +# ============================================================================= +# Todo-Project 시놀로지 배포 환경 설정 +# ============================================================================= +# 이 파일을 .env로 복사하여 사용하세요: cp env.synology.example .env + +# --- 필수 설정 (반드시 변경하세요!) --- +SECRET_KEY=YOUR_VERY_LONG_AND_RANDOM_SECRET_KEY_FOR_SYNOLOGY_PRODUCTION +POSTGRES_PASSWORD=YOUR_SECURE_DATABASE_PASSWORD + +# --- 포트 설정 --- +FRONTEND_PORT=4000 +BACKEND_PORT=9000 +DATABASE_PORT=5432 + +# --- 시놀로지 볼륨 경로 설정 --- +# 이미지 업로드 저장소 (volume1) +SYNOLOGY_UPLOADS_PATH=/volume1/todo-project/uploads + +# 설정 및 데이터베이스 저장소 (volume3) +SYNOLOGY_CONFIG_PATH=/volume3/docker/todo-project/config +SYNOLOGY_DB_PATH=/volume3/docker/todo-project/postgres + +# --- 애플리케이션 설정 --- +DEBUG=false +POSTGRES_USER=todo_user +POSTGRES_DB=todo_db + +# --- CORS 설정 (시놀로지 IP/도메인에 맞게 수정) --- +# 예시: CORS_ORIGINS=["http://192.168.1.100:4000", "https://your-domain.synology.me:4000"] +CORS_ORIGINS=["http://localhost:4000", "http://127.0.0.1:4000"] + +# --- Synology MailPlus 통합 설정 (선택사항) --- +SYNOLOGY_MAIL_SERVER= +SYNOLOGY_MAIL_USERNAME= +SYNOLOGY_MAIL_PASSWORD= +ENABLE_MAIL_MONITORING=false +MAIL_CHECK_INTERVAL=300 +TODO_KEYWORDS=todo,할일,task + +# ============================================================================= +# 시놀로지 배포 전 체크리스트: +# 1. SECRET_KEY와 POSTGRES_PASSWORD를 안전한 값으로 변경 +# 2. CORS_ORIGINS에 시놀로지 IP/도메인 추가 +# 3. 볼륨 경로가 실제 시놀로지 환경과 일치하는지 확인 +# 4. 포트가 시놀로지에서 사용 가능한지 확인 +# ============================================================================= \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 8562e2b..2261d98 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -4,8 +4,8 @@ FROM nginx:alpine # 정적 파일들을 nginx 웹 루트로 복사 COPY . /usr/share/nginx/html/ -# nginx 설정 파일 복사 (있는 경우) -# COPY nginx.conf /etc/nginx/nginx.conf +# nginx 설정 파일 복사 +COPY nginx.conf /etc/nginx/conf.d/default.conf # 포트 80 노출 EXPOSE 80 diff --git a/frontend/archive.html b/frontend/archive.html new file mode 100644 index 0000000..b5c19a2 --- /dev/null +++ b/frontend/archive.html @@ -0,0 +1,574 @@ + + + + + + 아카이브 - Todo Project + + + + + + + + + +
+ +
+ + +
+ +
+
+
+ + +
+ +
+ + + + + +
+
+
+ + +
+ +
+ + +
+ + + + + + + diff --git a/frontend/board.html b/frontend/board.html new file mode 100644 index 0000000..9afe110 --- /dev/null +++ b/frontend/board.html @@ -0,0 +1,1141 @@ + + + + + + 보드 - Todo Project + + + + + + + + + +
+ +
+ + +
+ +
+ +
+ + +
+ + + + + + + + + + diff --git a/frontend/calendar.html b/frontend/calendar.html deleted file mode 100644 index 07c0313..0000000 --- a/frontend/calendar.html +++ /dev/null @@ -1,398 +0,0 @@ - - - - - - 캘린더 - 마감 기한이 있는 일들 - - - - - -
- -
-
-
-
- - -

캘린더

- 마감 기한이 있는 일들 -
- -
- - - -
-
-
-
- - -
- -
-
- -

캘린더 관리

-
-

- 마감 기한이 있는 일들을 관리합니다. 우선순위에 따라 계획적으로 진행해보세요. -

-
-
-
🚨 긴급
-
3일 이내 마감
-
-
-
⚠️ 주의
-
1주일 이내 마감
-
-
-
📅 여유
-
1주일 이상 남음
-
-
-
- - -
-
-
- - - - - -
- -
- - -
-
-
- - -
-
-

- 마감 기한별 목록 -

-
- -
- -
- -
- -

아직 마감 기한이 설정된 일이 없습니다.

-

메인 페이지에서 항목을 등록하고 마감 기한을 설정해보세요!

- -
-
-
-
- - - - - - - - diff --git a/frontend/checklist.html b/frontend/checklist.html deleted file mode 100644 index 7e05218..0000000 --- a/frontend/checklist.html +++ /dev/null @@ -1,604 +0,0 @@ - - - - - - 체크리스트 - 기한 없는 일들 - - - - - -
- -
-
-
-
- - -

체크리스트

- 기한 없는 일들 -
- -
- - - -
-
-
-
- - -
- -
-
- -

체크리스트 관리

-
-

- 기한이 없는 일들을 관리합니다. 언제든 할 수 있는 일들을 체크해나가세요. -

-
-
-
📝 할 일
-
아직 완료하지 않은 일들
-
-
-
✅ 완료
-
완료한 일들
-
-
-
📊 진행률
-
0% 완료
-
-
-
- - -
-
-

- 전체 진행률 -

-
- 0 / 0 완료 -
-
-
-
-
-
- - -
-
-
- - - -
- -
- - - -
-
-
- - -
-
-

- 체크리스트 목록 -

-
- -
- -
- -
- -

아직 체크리스트 항목이 없습니다.

-

메인 페이지에서 기한 없는 항목을 등록해보세요!

- -
-
-
-
- - - - - - - - diff --git a/frontend/classify.html b/frontend/classify.html deleted file mode 100644 index 61994eb..0000000 --- a/frontend/classify.html +++ /dev/null @@ -1,623 +0,0 @@ - - - - - - INDEX - Todo Project - - - - - -
- -
-
-
-
- - -

INDEX

- 0 -
- -
- - - - -
-
-
-
- - -
- -
- -
-
-
- -
-
-

분류 대기

-

0

-
-
-
- -
-
-
- -
-
-

Todo 이동

-

0

-
-
-
- -
-
-
- -
-
-

캘린더 이동

-

0

-
-
-
- -
-
-
- -
-
-

체크리스트 이동

-

0

-
-
-
-
- - -
-
-
- - - - -
- -
- - - -
-
-
- - -
- -
- - - -
-
- - - - - - - - - diff --git a/frontend/dashboard.html b/frontend/dashboard.html deleted file mode 100644 index ce4c486..0000000 --- a/frontend/dashboard.html +++ /dev/null @@ -1,2325 +0,0 @@ - - - - - - 대시보드 - Todo Project - - - - - -
- -
-
-
-
- - -

대시보드

- -
- -
- - - - - - -
- - - -
- - - -
-
-
-
- - -
- -
- - -
-
-
-
- -
-
-

전체 Todo

-

0

-
-
-
- -
-
-
- -
-
-

완료됨

-

0

-
-
-
- -
-
-
- -
-
-

진행 중

-

0

-
-
-
- -
-
-
- -
-
-

오늘 할 일

-

0

-
-
-
- - -
-
-
-
- -
-
-

체크리스트

-

0

-
-
- - -
-
-
- - -
- -
-
-
- -

- -
- -
-
-
- Todo -
-
-
- 캘린더 -
-
-
- 체크리스트 -
-
-
-
- - -
-
- -
-
-
-
-
-
-
- - -
-
-
- - -
- -
-
-
-
- -
-
-

체크리스트

-

업로드된 항목들 • Todo나 캘린더로 변경 가능

-
-
-
-
진행률
-
-
-
-
- 0/0 -
-
-
-
- - -
-
- -
- -

아직 체크리스트 항목이 없습니다.

-

위의 업로드 버튼을 클릭해서 새로운 항목을 추가해보세요!

-

추가된 항목은 여기서 Todo나 캘린더로 변경할 수 있습니다.

-
-
-
-
-
- - -
- -
-
-

-

-
-
- - -
-

- 오늘의 일정 -

-
- -
-
- - -
-
-

- 체크리스트 -

-
- 0/0 -
-
- -
- -
-
-
-
-
- - - - - - - - - - - - - - diff --git a/frontend/inbox.html b/frontend/inbox.html new file mode 100644 index 0000000..92dfc22 --- /dev/null +++ b/frontend/inbox.html @@ -0,0 +1,798 @@ + + + + + + 수신함 - Todo Project + + + + + + + + + +
+ +
+ + +
+
+
+ +
+ + +
+
+ + + + + + + + + + + + + diff --git a/frontend/index.html b/frontend/index.html index e44af12..6380789 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -33,57 +33,77 @@ + + + + +
+ +
+ + +
+ +
+
+
+

+ + +

+

오늘 해야 할 일들

+
+
+
+ + +
+
+ +
+ + +
+
+ + + + + + + + + + diff --git a/frontend/todo.html b/frontend/todo.html deleted file mode 100644 index be222ab..0000000 --- a/frontend/todo.html +++ /dev/null @@ -1,353 +0,0 @@ - - - - - - Todo - 시작 날짜가 있는 일들 - - - - - -
- -
-
-
-
- - -

Todo

- 시작 날짜가 있는 일들 -
- -
- - - -
-
-
-
- - -
- -
-
- -

Todo 관리

-
-

- 시작 날짜가 정해진 일들을 관리합니다. 언제 시작할지 계획을 세우고 실행해보세요. -

-
-
-
📅 시작 예정
-
아직 시작하지 않은 일들
-
-
-
🔥 진행 중
-
현재 작업 중인 일들
-
-
-
✅ 완료
-
완료된 일들
-
-
-
- - -
-
-
- - - - -
- -
- - -
-
-
- - -
-
-

- Todo 목록 -

-
- -
- -
- -
- -

아직 시작 날짜가 설정된 일이 없습니다.

-

메인 페이지에서 항목을 등록하고 시작 날짜를 설정해보세요!

- -
-
-
-
- - - - - - - - diff --git a/frontend/upload.html b/frontend/upload.html new file mode 100644 index 0000000..5c75292 --- /dev/null +++ b/frontend/upload.html @@ -0,0 +1,853 @@ + + + + + + 새 메모 - Todo Project + + + + + + + + + + + + + + +
+ +
+ + +
+ +
+
+
+

+ + 최근 저장된 메모 +

+
+ +
+
+
+
+ + +
+
+ +
+ +
+ + +
+ + +
+
+ + +
+ + +
+ + + +
+ + + + + +
+
+ +
+
+
+
+
+ + + + + + + + + + +