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
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
๋ถ๋ฅํ ํญ๋ชฉ์ด ์์ต๋๋ค
-
์๋ก์ด ํญ๋ชฉ์ ์
๋ก๋ํ๊ฑฐ๋ ๋ฉ์ผ์ ๋ฐ์ผ๋ฉด ์ฌ๊ธฐ์ ํ์๋ฉ๋๋ค.
-
-
-
-
-
-
-
-
-
-
-
-
-
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๋ ์บ๋ฆฐ๋๋ก ๋ณ๊ฒฝ ๊ฐ๋ฅ
-
-
-
-
-
-
-
-
-
-
-
-
-
์์ง ์ฒดํฌ๋ฆฌ์คํธ ํญ๋ชฉ์ด ์์ต๋๋ค.
-
์์ ์
๋ก๋ ๋ฒํผ์ ํด๋ฆญํด์ ์๋ก์ด ํญ๋ชฉ์ ์ถ๊ฐํด๋ณด์ธ์!
-
์ถ๊ฐ๋ ํญ๋ชฉ์ ์ฌ๊ธฐ์ 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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
์ค๋ ํด์ผ ํ ์ผ๋ค
+
+
+
+
+
+
+
+
+
+
+
+
+
์งํํ Todo๊ฐ ์์ต๋๋ค
+
์์ ํจ์์ ์๋ก์ด Todo๋ฅผ ์ถ๊ฐํด๋ณด์ธ์
+
+ ์์ ํจ์ผ๋ก ์ด๋
+
+
+
+
+
+
+
+
+
+
+ ์๋ก์ด ๋ ์ง ์ ํ
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+